Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:29:03 -04:00

403 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
This module has no I/O and no rendering, on purpose, so that unit tests can run it without a
device, a terminal, or the network. It changes latitude and longitude into the Web Mercator
"world pixel" space of slippy-map vector tiles. It models the :class:`Viewport` on the screen
(which braille dot shows which coordinate), and it measures great-circle distances.
The map renders one braille **dot** for each Web Mercator pixel at the zoom of the viewport.
Thus a viewport of ``dot_w`` × ``dot_h`` dots shows exactly that many Mercator pixels. A pan
or a zoom then only moves or scales this area over the world. In a monospace font, the
sub-cells of a braille glyph (one for each dot) are approximately square: 2 dots wide and 4
dots tall, in a cell that is approximately two times as tall as it is wide. Thus Mercator
pixels show on the screen without visible distortion.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
#: The mean radius of the Earth in kilometres, for haversine distances.
EARTH_RADIUS_KM = 6371.0088
#: The edge of a Web Mercator tile in pixels at its own zoom (the slippy-map convention). At
#: a given zoom, the world is a square of ``TILE_PX * 2**zoom`` pixels.
TILE_PX = 256
#: The default fraction of nodes that a map viewport fits: the densest half. Thus a few
#: distant outliers do not zoom the full mesh out to a continent. Refer to
#: :meth:`Viewport.fit`.
#:
#: It is in this module, next to the viewport mathematics that it is a parameter of, and not
#: in the map screen that made it. The ``map`` subcommand must have it as a ``--fraction``
#: default at CLI registration time, which occurs for each tool at each startup. When the
#: constant was in :mod:`meshterm.ui.map_screen`, the import pulled the full map stack (and,
#: through the tile downloader of the basemap, ``urllib.request`` → ``http.client`` →
#: ``ssl``) into each run, also runs that never open a map. This module is pure arithmetic
#: and is already on the boot path, so the constant costs nothing here. It is also the
#: default of the ``map_view_fraction`` preference. The registry names this constant and
#: does not type the number again, so the code default and the preference default are one
#: value.
DEFAULT_VIEW_FRACTION = 0.5
@dataclass(frozen=True, slots=True)
class BBox:
"""An axis-aligned bounding box of latitude and longitude (decimal degrees)."""
min_lat: float
min_lon: float
max_lat: float
max_lon: float
@classmethod
def around(cls, points: list[tuple[float, float]]) -> BBox:
"""Return the smallest box that contains each ``(lat, lon)`` point.
Args:
points: One or more latitude and longitude pairs (must not be empty).
Returns:
The bounding box that contains the points.
Raises:
ValueError: If ``points`` is empty.
"""
if not points:
raise ValueError("cannot build a bounding box from no points")
lats = [lat for lat, _ in points]
lons = [lon for _, lon in points]
return cls(min(lats), min(lons), max(lats), max(lons))
@property
def center(self) -> tuple[float, float]:
"""The centre of the box, as a ``(lat, lon)`` pair."""
return ((self.min_lat + self.max_lat) / 2, (self.min_lon + self.max_lon) / 2)
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Return the great-circle distance between two points, in kilometres.
Args:
lat1: The latitude of the first point (degrees).
lon1: The longitude of the first point (degrees).
lat2: The latitude of the second point (degrees).
lon2: The longitude of the second point (degrees).
Returns:
The distance along the surface of the Earth, in kilometres.
"""
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
# Clamped, because the rounding can push ``a`` a very small amount above 1 for points
# that are almost antipodal. Then ``asin`` raises a domain error, and does not return
# half the planet.
return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a)))
def _central_points(
points: list[tuple[float, float]], fraction: float
) -> list[tuple[float, float]]:
"""Return the ``fraction`` of ``points`` nearest to their median centre (the dense core).
The centre is the median on each axis, so that outliers do not pull it. The points are
sorted by their great-circle distance from the centre. The function always keeps a
minimum of two points (so that the result still limits a zoom). When the count to keep
reaches the length of the list, the function returns the full list.
Args:
points: Latitude and longitude pairs (not empty).
fraction: The portion to keep, ``0 < fraction <= 1``.
Returns:
The nearest ``ceil(len(points) * fraction)`` points (minimum 2), or all of them.
"""
n = len(points)
keep = max(2, math.ceil(n * fraction))
if keep >= n:
return points
lats = sorted(lat for lat, _ in points)
lons = sorted(lon for _, lon in points)
mid = n // 2
med_lat = lats[mid] if n % 2 else (lats[mid - 1] + lats[mid]) / 2
med_lon = lons[mid] if n % 2 else (lons[mid - 1] + lons[mid]) / 2
ordered = sorted(points, key=lambda p: haversine_km(p[0], p[1], med_lat, med_lon))
return ordered[:keep]
def clamp_lat(lat: float) -> float:
"""Clamp a latitude to the Web Mercator limit (~±85.051°), where the projection is finite."""
return max(-85.05112878, min(85.05112878, lat))
def usable_fix(lat: float, lon: float) -> bool:
"""Whether an advertised ``(lat, lon)`` is a real position that is worth a plot.
The location in the advert of a node is usable only if it is a true fix. It is not
usable in two cases:
* **out of range**: the latitude must be in ±90°, and the longitude in ±180°. Some
firmware and adverts report nonsense (MeshTerm heard a MeshCore companion with the
advert ``lat -97, lon -1042``). If MeshTerm projects that position, the viewport
goes off the world. Then the map is a screen of empty or water fill, which looks
solid black.
* **null island**: a companion with no GPS lock advertises a latitude and a
longitude of zero. This position projects to the empty middle of the Atlantic. A
node there is worse than useless: if the viewport fits only that node, the full
viewport goes onto open ocean (again, solid black water).
In both cases, MeshTerm treats the fix as absent, so the node has no location. This
is the shared guard that the map markers and the location preview of the Node detail
screen both use.
"""
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
return False
return not (abs(lat) < 1e-6 and abs(lon) < 1e-6)
def lonlat_to_world(lat: float, lon: float, zoom: float) -> tuple[float, float]:
"""Project ``(lat, lon)`` to Web Mercator world pixels at ``zoom``.
Args:
lat: The latitude in degrees (clamped to the Mercator limit).
lon: The longitude in degrees.
zoom: The zoom level (it can be a fraction).
Returns:
``(x, y)`` in world pixels. The world is ``TILE_PX * 2**zoom`` on each axis, and
``y`` increases to the south (north is up, at a smaller ``y``).
"""
scale = TILE_PX * (2.0**zoom)
x = (lon + 180.0) / 360.0 * scale
s = math.sin(math.radians(clamp_lat(lat)))
y = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * scale
return x, y
def world_to_lonlat(x: float, y: float, zoom: float) -> tuple[float, float]:
"""Invert :func:`lonlat_to_world`: change world pixels at ``zoom`` back to ``(lat, lon)``.
Args:
x: The world-pixel x.
y: The world-pixel y.
zoom: The zoom at which the pixels were calculated.
Returns:
The ``(lat, lon)`` in degrees.
"""
scale = TILE_PX * (2.0**zoom)
lon = x / scale * 360.0 - 180.0
n = math.pi - 2 * math.pi * y / scale
lat = math.degrees(math.atan(math.sinh(n)))
return lat, lon
@dataclass(frozen=True, slots=True)
class Viewport:
"""The visible area of the world on the screen: the position that each braille dot shows.
One dot is one Web Mercator pixel at :attr:`zoom`. Thus the visible area is exactly
``dot_w`` × ``dot_h`` Mercator pixels, with its centre at ``(center_lat, center_lon)``.
A pan or a zoom returns a new viewport. Nothing in this class mutates.
Attributes:
center_lat: The latitude at the centre of the viewport.
center_lon: The longitude at the centre of the viewport.
zoom: The viewport zoom (an integer here). It can be more than the maximum of the
tile source. In that case, the map magnifies the tiles of a lower zoom (refer to
:meth:`tiles` and :meth:`feature_to_dot`).
dot_w: The width of the viewport in braille dots.
dot_h: The height of the viewport in braille dots.
"""
center_lat: float
center_lon: float
zoom: int
dot_w: int
dot_h: int
@property
def origin_world(self) -> tuple[float, float]:
"""The top-left corner of the viewport in world pixels at :attr:`zoom`."""
cx, cy = lonlat_to_world(self.center_lat, self.center_lon, self.zoom)
return cx - self.dot_w / 2, cy - self.dot_h / 2
def lonlat_to_dot(self, lat: float, lon: float) -> tuple[float, float]:
"""Project a coordinate to a dot position in the viewport (it can be off the screen)."""
ox, oy = self.origin_world
wx, wy = lonlat_to_world(lat, lon, self.zoom)
return wx - ox, wy - oy
def tile_zoom(self, max_tile_zoom: int) -> int:
"""The tile zoom to download: the viewport zoom, limited to the maximum of the source."""
return max(0, min(self.zoom, max_tile_zoom))
def tiles(self, max_tile_zoom: int) -> list[tuple[int, int, int]]:
"""Return the ``(z, x, y)`` tiles that cover the viewport at the downloadable tile zoom.
When the viewport zoom is more than ``max_tile_zoom``, the function returns the tiles
of a lower zoom that cover the same ground (the renderer magnifies them). Thus a zoom
past the limit of the source still works.
Args:
max_tile_zoom: The highest zoom that the tile source serves.
Returns:
Tile coordinates, clamped to the valid range, without duplicates, in row-major
order.
"""
tz = self.tile_zoom(max_tile_zoom)
scale = 2.0 ** (self.zoom - tz) # viewport pixels for each tile-zoom pixel
ox, oy = self.origin_world
n = 2**tz
# The visible rectangle in tile-zoom world pixels, then in tile indices.
tx0 = int((ox / scale) // TILE_PX)
tx1 = int(((ox + self.dot_w) / scale) // TILE_PX)
ty0 = int((oy / scale) // TILE_PX)
ty1 = int(((oy + self.dot_h) / scale) // TILE_PX)
out: list[tuple[int, int, int]] = []
for ty in range(ty0, ty1 + 1):
if not 0 <= ty < n:
continue
for tx in range(tx0, tx1 + 1):
out.append((tz, tx % n, ty)) # wrap x around the antimeridian
return out
def tile_transform(
self, tile_x: int, tile_y: int, tile_zoom: int, extent: int
) -> tuple[float, float, float]:
"""Return ``(base_x, base_y, step)``: the change from local tile coordinates to dots.
:meth:`feature_to_dot` is the readable form of the same projection, but it is a call
for each vertex. One frame of the map projects tens of thousands of vertices: 107k of
them on a map of a downtown area. Each call calculates :attr:`origin_world` again (a
``sin`` and ``log`` pair), and a ``2**`` for a value that does not change in the full
frame. That was more than half the cost to draw the map.
The projection is affine in the local coordinates of the tile. Thus all of that
becomes three numbers that the caller can move out of its loop::
dot_x = base_x + lx * step
dot_y = base_y + ly * step
Then each vertex costs two multiplications and two additions, inline, with no call
at all. The caller must write that arithmetic in its own comprehension, and not get
a closure back. After the trigonometry is gone, a function call for each vertex is
itself most of the remaining cost.
Args:
tile_x: The x index of the tile at ``tile_zoom``.
tile_y: The y index of the tile at ``tile_zoom``.
tile_zoom: The zoom at which the tile was downloaded.
extent: The internal coordinate extent of the tile (for example, 4096).
Returns:
The ``(base_x, base_y, step)`` coefficients that are described above.
"""
span = TILE_PX * (2.0 ** (self.zoom - tile_zoom))
ox, oy = self.origin_world
return tile_x * span - ox, tile_y * span - oy, span / extent
def feature_to_dot(
self, tile_x: int, tile_y: int, tile_zoom: int, extent: int, lx: float, ly: float
) -> tuple[float, float]:
"""Project a point in local tile coordinates to a dot position in this viewport.
Args:
tile_x: The x index of the tile at ``tile_zoom``.
tile_y: The y index of the tile at ``tile_zoom``.
tile_zoom: The zoom at which the tile was downloaded.
extent: The internal coordinate extent of the tile (for example, 4096).
lx: The local x in the tile, ``0..extent``.
ly: The local y in the tile, ``0..extent``.
Returns:
The ``(x, y)`` dot position. It can be outside the canvas, and the caller clips
it.
"""
scale = 2.0 ** (self.zoom - tile_zoom)
wx = (tile_x + lx / extent) * TILE_PX * scale
wy = (tile_y + ly / extent) * TILE_PX * scale
ox, oy = self.origin_world
return wx - ox, wy - oy
def panned(self, frac_x: float, frac_y: float) -> Viewport:
"""Return a viewport moved by a fraction of its own width and height.
Args:
frac_x: The move east, as a fraction of the viewport width (negative = west).
frac_y: The move south, as a fraction of the viewport height (negative = north).
Returns:
A new :class:`Viewport` at the moved centre, with the same zoom and size.
"""
cx, cy = lonlat_to_world(self.center_lat, self.center_lon, self.zoom)
cx += frac_x * self.dot_w
cy += frac_y * self.dot_h
lat, lon = world_to_lonlat(cx, cy, self.zoom)
return Viewport(clamp_lat(lat), lon, self.zoom, self.dot_w, self.dot_h)
def zoomed(self, delta: int, *, min_zoom: int = 2, max_zoom: int = 19) -> Viewport:
"""Return a viewport zoomed by ``delta`` levels on the same centre (clamped)."""
z = max(min_zoom, min(max_zoom, self.zoom + delta))
return Viewport(self.center_lat, self.center_lon, z, self.dot_w, self.dot_h)
def resized(self, dot_w: int, dot_h: int) -> Viewport:
"""Return the same viewport, with the same centre, but at a new canvas size."""
return Viewport(self.center_lat, self.center_lon, self.zoom, dot_w, dot_h)
@classmethod
def fit(
cls,
points: list[tuple[float, float]],
dot_w: int,
dot_h: int,
*,
pad: float = 0.18,
min_zoom: int = 2,
max_zoom: int = 16,
default_zoom: int = 14,
fraction: float = 1.0,
) -> Viewport:
"""Build a viewport that fits ``points``: centred on them, at the highest zoom that fits.
Args:
points: The latitude and longitude pairs to fit (can be empty).
dot_w: The canvas width in dots.
dot_h: The canvas height in dots.
pad: The fraction of the canvas that is kept as a margin around the points.
min_zoom: The lowest zoom to consider.
max_zoom: The highest zoom to consider.
default_zoom: The zoom to use when the points do not limit it (0 or 1 point).
fraction: The fraction of the points to fit, ``0 < fraction <= 1``. Below ``1``,
the viewport fits only the densest core (the points nearest to the median
centre). Thus a few distant outliers cannot force the full viewport to zoom
out. The other nodes go off the edges.
Returns:
A :class:`Viewport` centred on the fitted points, at a zoom where they fit with
a margin. An empty input centres on the world at ``min_zoom``.
"""
if not points:
return cls(0.0, 0.0, min_zoom, dot_w, dot_h)
if fraction < 1.0:
points = _central_points(points, fraction)
box = BBox.around(points)
center_lat, center_lon = box.center
if len(points) == 1 or (box.min_lat == box.max_lat and box.min_lon == box.max_lon):
return cls(center_lat, center_lon, default_zoom, dot_w, dot_h)
avail_w = dot_w * (1 - pad)
avail_h = dot_h * (1 - pad)
chosen = min_zoom
for z in range(max_zoom, min_zoom - 1, -1):
x0, y0 = lonlat_to_world(box.max_lat, box.min_lon, z) # NW corner
x1, y1 = lonlat_to_world(box.min_lat, box.max_lon, z) # SE corner
if abs(x1 - x0) <= avail_w and abs(y1 - y0) <= avail_h:
chosen = z
break
return cls(center_lat, center_lon, chosen, dot_w, dot_h)