Files
MeshTerm/meshtools/core/geo.py
T
Jean-Pierre MartineauandClaude Opus 4.8 d652a4e012 Add an interactive braille OpenStreetMap map of mesh nodes
New `map` tool (menu + `meshtools map` CLI) that plots the mesh's
location-sharing nodes over a real OSM street basemap rendered as Unicode
braille — streets, rivers, water, and place names — the mapscii idea, but
able to plot our own nodes and live inside the TUI.

- core/mvt.py: dependency-free MVT/protobuf vector-tile decoder.
- core/geo.py: Web Mercator + a pannable/zoomable Viewport and tile math.
- services/basemap.py: OpenFreeMap vector tiles (free, no API key), resolved
  from the versioned TileJSON, disk-cached, offline-safe (stdlib urllib only).
- ui/mapcanvas.py: colored braille canvas (per-cell priority, line raster,
  polygon fill, collision-avoided labels), emits truecolor ANSI.
- ui/map_render.py: compositor — basemap styling + node markers with names.
- ui/map_screen.py: full-screen screen; wasd/arrows pan, =/- zoom, r reset,
  Esc/q exit; background tile loading. Offline falls back to nodes on a grid.
- tools/map.py: interactive in the menu, one-shot static render on the CLI.

Repeaters are prioritised over leaf nodes via advert type: threaded `node_type`
through Observation/HeardNode and the persistence layer (schema v4->v5, with an
idempotent migration adding observations.node_type). Added
TuiSession.base_body_size() so a full-screen screen can size itself to the frame.

Tests: tests/test_map.py (24) cover MVT decode against a real cached tile
fixture, projection, the canvas, the compositor, offline behavior, and the
interactive pan/zoom/reset logic. Full suite: 261 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:38:39 -04:00

282 lines
11 KiB
Python
Raw 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.
"""Pure geographic helpers for the terminal map: Web Mercator, tiles, and viewports.
This module is deliberately I/O-free and rendering-free so it can be unit-tested without a
radio, a terminal, or the network. It turns latitude/longitude into the Web Mercator "world
pixel" space that slippy-map vector tiles live in, models the on-screen :class:`Viewport`
(which braille dot maps to which coordinate), and measures great-circle distances.
The map renders one braille **dot** per Web Mercator pixel at the viewport's zoom, so a
viewport of ``dot_w`` × ``dot_h`` dots shows exactly that many mercator pixels — panning and
zooming are then just moving and scaling this window over the world. Braille sub-cells are
about square in a monospace font (2 dots wide, 4 tall, in a cell roughly twice as tall as
wide), so mercator pixels land on screen without obvious distortion.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
#: Mean radius of the Earth in kilometres, used for haversine distances.
EARTH_RADIUS_KM = 6371.0088
#: Web Mercator tile edge in pixels at its own zoom — the slippy-map convention. The world
#: is ``TILE_PX * 2**zoom`` pixels square at a given zoom.
TILE_PX = 256
@dataclass(frozen=True, slots=True)
class BBox:
"""An axis-aligned latitude/longitude bounding box (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 tightest box containing every ``(lat, lon)`` point.
Args:
points: One or more latitude/longitude pairs (must be non-empty).
Returns:
The enclosing bounding box.
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 box's centre 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: First point's latitude (degrees).
lon1: First point's longitude (degrees).
lat2: Second point's latitude (degrees).
lon2: Second point's longitude (degrees).
Returns:
The distance along the Earth's surface 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
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
def clamp_lat(lat: float) -> float:
"""Clamp latitude to the Web Mercator limit (~±85.051°) where the projection is finite."""
return max(-85.05112878, min(85.05112878, lat))
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: Latitude in degrees (clamped to the mercator limit).
lon: Longitude in degrees.
zoom: Zoom level (may be fractional).
Returns:
``(x, y)`` in world pixels, where the world spans ``TILE_PX * 2**zoom`` on each axis
and ``y`` grows southward (north is up / 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`: world pixels at ``zoom`` back to ``(lat, lon)``.
Args:
x: World-pixel x.
y: World-pixel y.
zoom: The zoom the pixels were computed at.
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 on-screen window over the world: what each braille dot maps to.
One dot equals one Web Mercator pixel at :attr:`zoom`, so the visible area is exactly
``dot_w`` × ``dot_h`` mercator pixels centred on ``(center_lat, center_lon)``. Panning and
zooming return new viewports; nothing here mutates.
Attributes:
center_lat: Latitude at the centre of the view.
center_lon: Longitude at the centre of the view.
zoom: Display zoom (integer here; may exceed the tile source's max, in which case
lower-zoom tiles are magnified — see :meth:`tiles` / :meth:`feature_to_dot`).
dot_w: Viewport width in braille dots.
dot_h: Viewport height 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]:
"""Top-left corner of the view 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 (possibly off-screen) dot position in the view."""
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 fetch: the display zoom, capped at the source's max."""
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 covering the view at the fetchable tile zoom.
When the display zoom exceeds ``max_tile_zoom`` the lower-zoom tiles that cover the
same ground are returned (the renderer magnifies them), so zooming in past the
source's limit still works.
Args:
max_tile_zoom: The highest zoom the tile source actually serves.
Returns:
Tile coordinates, clamped to the valid range, de-duplicated in row-major order.
"""
tz = self.tile_zoom(max_tile_zoom)
scale = 2.0 ** (self.zoom - tz) # display px per tile-zoom px
ox, oy = self.origin_world
n = 2**tz
# Visible rect 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 feature_to_dot(
self, tile_x: int, tile_y: int, tile_zoom: int, extent: int, lx: float, ly: float
) -> tuple[float, float]:
"""Project a tile-local point to a dot position in this viewport.
Args:
tile_x: The tile's x index at ``tile_zoom``.
tile_y: The tile's y index at ``tile_zoom``.
tile_zoom: The zoom the tile was fetched at.
extent: The tile's internal coordinate extent (e.g. 4096).
lx: Local x within the tile, ``0..extent``.
ly: Local y within the tile, ``0..extent``.
Returns:
The ``(x, y)`` dot position (may be outside the canvas; the caller clips).
"""
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 shifted by a fraction of its own width/height.
Args:
frac_x: Eastward shift as a fraction of the view width (negative = west).
frac_y: Southward shift as a fraction of the view height (negative = north).
Returns:
A new :class:`Viewport` at the shifted centre, 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 about 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 view centred as before 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,
) -> "Viewport":
"""Build a viewport framing ``points`` — centred on them at the tightest fitting zoom.
Args:
points: Latitude/longitude pairs to frame (may be empty).
dot_w: Canvas width in dots.
dot_h: Canvas height in dots.
pad: Fraction of the canvas kept as margin around the points.
min_zoom: Lowest zoom to consider.
max_zoom: Highest zoom to consider.
default_zoom: Zoom used when the points don't constrain it (0 or 1 point).
Returns:
A :class:`Viewport` centred on the points at a zoom where they fit with margin.
Empty input centres on the world at ``min_zoom``.
"""
if not points:
return cls(0.0, 0.0, min_zoom, dot_w, dot_h)
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)