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>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-07 17:38:39 -04:00
1 parent a03dedb09c
commit d652a4e012
14 files changed
+2233 -7

No files matched your search

+25 -1
View File
@@ -22,7 +22,16 @@ from typing import Callable, Optional
from .channels import CHANNEL_SLOT_PROBE_CAP
from .events import MeshEvent
from .models import Ack, Contact, Hop, Message, Observation, TraceResult
from .models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
Ack,
Contact,
Hop,
Message,
Observation,
TraceResult,
)
#: Module logger; enable DEBUG on ``meshtools.core.connection`` to trace the message pump.
_log = logging.getLogger(__name__)
@@ -1265,10 +1274,14 @@ class MockDevice(Device):
``advert`` otherwise, carrying a location for the simulated repeaters.
"""
lat_lon = self._MOCK_LOCATIONS.get(contact.name)
# The two located nodes are the simulated repeaters (fixed infrastructure); the rest
# advertise as ordinary chat nodes, so the map has both classes to prioritise.
node_type = NODE_TYPE_REPEATER if contact.name in self._MOCK_LOCATIONS else NODE_TYPE_CHAT
return Observation(
node=contact.key_prefix or contact.public_key[:12],
name=contact.name,
kind="telemetry" if seq % 4 == 3 else "advert",
node_type=node_type,
snr=round(self._rng.gauss(6.0, 3.0), 1),
rssi=round(self._rng.gauss(-95.0, 8.0), 1),
lat=lat_lon[0] if lat_lon else None,
@@ -1428,6 +1441,7 @@ def observation_from_event(event, kind: str) -> Optional[Observation]: # noqa:
node=node,
name=payload.get("adv_name") or payload.get("name"),
kind=kind,
node_type=_as_int(payload.get("adv_type", payload.get("type"))),
snr=_as_float(payload.get("snr")),
rssi=_as_float(payload.get("rssi")),
lat=_as_float(lat) if lat else None,
@@ -1487,6 +1501,16 @@ def _as_float(value: object) -> Optional[float]:
return None
def _as_int(value: object) -> Optional[int]:
"""Best-effort int conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _advert_time(last_advert: object) -> Optional[datetime]:
"""Convert a contact's ``last_advert`` Unix timestamp to a UTC datetime.
+281
View File
@@ -0,0 +1,281 @@
"""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)
+29
View File
@@ -15,6 +15,22 @@ from typing import Optional
#: Label used for our own (local) device when framing a trace path's endpoints.
LOCAL_DEVICE_LABEL = "us"
# MeshCore advert types — the low nibble of an advert's flag byte — classify the kind of
# node an advert announces. Repeaters are fixed infrastructure and carry the mesh, so
# location-aware features (e.g. the map) prioritise them over ordinary leaf nodes.
NODE_TYPE_CHAT = 1
NODE_TYPE_REPEATER = 2
NODE_TYPE_ROOM = 3
NODE_TYPE_SENSOR = 4
#: Human-readable label for each advert type, for legends and summaries.
NODE_TYPE_LABELS = {
NODE_TYPE_CHAT: "node",
NODE_TYPE_REPEATER: "repeater",
NODE_TYPE_ROOM: "room",
NODE_TYPE_SENSOR: "sensor",
}
def utcnow() -> datetime:
"""Return a timezone-aware UTC timestamp.
@@ -54,6 +70,8 @@ class Observation:
node: Key prefix / hash identifying the transmitting node, if known.
name: Friendly name advertised by the node, if carried.
kind: Packet class, e.g. ``advert`` or ``telemetry``.
node_type: Advert type of the transmitting node (see the ``NODE_TYPE_*`` constants),
e.g. :data:`NODE_TYPE_REPEATER`; ``None`` when the packet did not carry it.
snr: Signal-to-noise ratio (dB) the companion measured, if reported.
rssi: Received signal strength (dBm), if reported.
lat: Advertised latitude (decimal degrees), when the node shares location.
@@ -65,6 +83,7 @@ class Observation:
node: Optional[str]
name: Optional[str] = None
kind: str = "advert"
node_type: Optional[int] = None
snr: Optional[float] = None
rssi: Optional[float] = None
lat: Optional[float] = None
@@ -277,6 +296,8 @@ class HeardNode:
last_seen: Timestamp of the most recent observation.
lat: Most recent advertised latitude, if the node shared one.
lon: Most recent advertised longitude, if the node shared one.
node_type: Most recent advert type seen for the node (see the ``NODE_TYPE_*``
constants), if any observation carried it.
"""
node: Optional[str]
@@ -288,12 +309,18 @@ class HeardNode:
last_seen: datetime
lat: Optional[float] = None
lon: Optional[float] = None
node_type: Optional[int] = None
@property
def has_location(self) -> bool:
"""Whether this node reported a usable latitude/longitude."""
return self.lat is not None and self.lon is not None
@property
def is_repeater(self) -> bool:
"""Whether this node advertised itself as a repeater."""
return self.node_type == NODE_TYPE_REPEATER
@classmethod
def from_observations(
cls, node: Optional[str], observations: list["Observation"]
@@ -315,6 +342,7 @@ class HeardNode:
(o for o in reversed(ordered) if o.lat is not None and o.lon is not None), None
)
name = next((o.name for o in reversed(ordered) if o.name), None)
node_type = next((o.node_type for o in reversed(ordered) if o.node_type is not None), None)
return cls(
node=node,
name=name,
@@ -325,6 +353,7 @@ class HeardNode:
last_seen=latest.observed_at,
lat=located.lat if located else None,
lon=located.lon if located else None,
node_type=node_type,
)
+278
View File
@@ -0,0 +1,278 @@
"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
than pull in a protobuf runtime and a geometry library, this decodes the small subset the map
needs by hand: the protobuf wire format (varints, length-delimited fields) and the MVT
geometry command encoding (MoveTo/LineTo/ClosePath with zig-zag deltas). It is pure and
offline — the network fetch lives in :mod:`meshtools.services.basemap`.
The result is a list of :class:`Layer`, each with decoded :class:`Feature` geometry in
*tile-local* integer coordinates (``0..extent``) and a resolved ``tags`` dict, ready to be
projected to the screen by :class:`meshtools.core.geo.Viewport`.
"""
from __future__ import annotations
import gzip
from dataclasses import dataclass, field
from typing import Any, Optional
# MVT geometry types (Feature.type).
GEOM_POINT = 1
GEOM_LINE = 2
GEOM_POLYGON = 3
# Geometry command ids (low 3 bits of a command integer).
_CMD_MOVE_TO = 1
_CMD_LINE_TO = 2
_CMD_CLOSE = 7
class _Reader:
"""A minimal protobuf wire-format reader over a byte buffer."""
__slots__ = ("b", "i")
def __init__(self, b: bytes) -> None:
self.b = b
self.i = 0
def eof(self) -> bool:
return self.i >= len(self.b)
def varint(self) -> int:
"""Read a base-128 varint."""
result = shift = 0
while True:
byte = self.b[self.i]
self.i += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result
shift += 7
def tag(self) -> tuple[int, int]:
"""Read a field tag, returning ``(field_number, wire_type)``."""
key = self.varint()
return key >> 3, key & 0x7
def blob(self) -> bytes:
"""Read a length-delimited byte string."""
n = self.varint()
v = self.b[self.i : self.i + n]
self.i += n
return v
def skip(self, wire_type: int) -> None:
"""Skip a field of the given wire type."""
if wire_type == 0:
self.varint()
elif wire_type == 2:
self.blob()
elif wire_type == 5:
self.i += 4
elif wire_type == 1:
self.i += 8
else: # pragma: no cover - groups are obsolete and never appear in MVT
raise ValueError(f"unsupported wire type {wire_type}")
def _zigzag(n: int) -> int:
"""Decode a protobuf zig-zag encoded signed integer."""
return (n >> 1) ^ -(n & 1)
@dataclass(slots=True)
class Feature:
"""One decoded vector-tile feature.
Attributes:
geom_type: :data:`GEOM_POINT`, :data:`GEOM_LINE`, or :data:`GEOM_POLYGON`.
rings: The geometry as a list of parts, each a list of ``(x, y)`` integer points in
tile-local coordinates (``0..extent``). Points have one part holding every point;
lines/polygons have one part per line-string / ring.
tags: Resolved attribute dict (e.g. ``{"class": "primary", "name": "Rue X"}``).
"""
geom_type: int
rings: list[list[tuple[int, int]]]
tags: dict[str, Any]
def get(self, key: str, default: Any = None) -> Any:
"""Return a tag value, or ``default`` if absent."""
return self.tags.get(key, default)
@property
def name(self) -> Optional[str]:
"""The feature's display name, preferring the local ``name`` then latin fallbacks."""
for key in ("name", "name:latin", "name:en", "name_en", "name_int"):
val = self.tags.get(key)
if val:
return str(val)
return None
@dataclass(slots=True)
class Layer:
"""A named vector-tile layer and its decoded features.
Attributes:
name: Layer id (e.g. ``transportation``, ``water``, ``place``).
extent: The tile's internal coordinate extent (typically 4096).
features: The decoded features.
"""
name: str
extent: int
features: list[Feature] = field(default_factory=list)
def _decode_geometry(cmds: list[int]) -> list[list[tuple[int, int]]]:
"""Decode an MVT geometry command stream into a list of point rings."""
rings: list[list[tuple[int, int]]] = []
current: list[tuple[int, int]] = []
x = y = 0
i = 0
n = len(cmds)
while i < n:
command = cmds[i]
i += 1
cmd_id = command & 0x7
count = command >> 3
if cmd_id == _CMD_MOVE_TO:
for _ in range(count):
x += _zigzag(cmds[i])
y += _zigzag(cmds[i + 1])
i += 2
# Each MoveTo starts a new part (multipoint keeps them in one part below).
if current:
rings.append(current)
current = [(x, y)]
elif cmd_id == _CMD_LINE_TO:
for _ in range(count):
x += _zigzag(cmds[i])
y += _zigzag(cmds[i + 1])
i += 2
current.append((x, y))
elif cmd_id == _CMD_CLOSE:
if current:
current.append(current[0]) # close the ring back to its start
if current:
rings.append(current)
return rings
def _decode_value(buf: bytes) -> Any:
"""Decode an MVT attribute Value message into a Python scalar."""
r = _Reader(buf)
while not r.eof():
field_no, wire = r.tag()
if field_no == 1 and wire == 2:
return r.blob().decode("utf-8", "ignore") # string_value
if field_no == 2 and wire == 5: # float_value
import struct
v = struct.unpack("<f", r.b[r.i : r.i + 4])[0]
r.i += 4
return v
if field_no == 3 and wire == 1: # double_value
import struct
v = struct.unpack("<d", r.b[r.i : r.i + 8])[0]
r.i += 8
return v
if field_no in (4, 5) and wire == 0: # int_value / uint_value
return r.varint()
if field_no == 6 and wire == 0: # sint_value
return _zigzag(r.varint())
if field_no == 7 and wire == 0: # bool_value
return bool(r.varint())
r.skip(wire)
return None
def _packed_uints(buf: bytes) -> list[int]:
"""Decode a packed repeated uint32 field into a list of ints."""
r = _Reader(buf)
out: list[int] = []
while not r.eof():
out.append(r.varint())
return out
def _decode_feature(buf: bytes, keys: list[str], values: list[Any]) -> Optional[Feature]:
"""Decode a single Feature message, resolving its tags against the layer's key/value pools."""
r = _Reader(buf)
geom_type = 0
tag_ints: list[int] = []
geom_ints: list[int] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 2 and wire == 2:
tag_ints = _packed_uints(r.blob())
elif field_no == 3 and wire == 0:
geom_type = r.varint()
elif field_no == 4 and wire == 2:
geom_ints = _packed_uints(r.blob())
else:
r.skip(wire)
if not geom_ints:
return None
tags: dict[str, Any] = {}
for k, v in zip(tag_ints[0::2], tag_ints[1::2]):
if 0 <= k < len(keys) and 0 <= v < len(values):
tags[keys[k]] = values[v]
return Feature(geom_type=geom_type, rings=_decode_geometry(geom_ints), tags=tags)
def _decode_layer(buf: bytes) -> Layer:
"""Decode a single Layer message and all of its features."""
r = _Reader(buf)
name = ""
extent = 4096
keys: list[str] = []
values: list[Any] = []
feature_blobs: list[bytes] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 1 and wire == 2:
name = r.blob().decode("utf-8", "ignore")
elif field_no == 2 and wire == 2:
feature_blobs.append(r.blob())
elif field_no == 3 and wire == 2:
keys.append(r.blob().decode("utf-8", "ignore"))
elif field_no == 4 and wire == 2:
values.append(_decode_value(r.blob()))
elif field_no == 5 and wire == 0:
extent = r.varint()
else:
r.skip(wire)
layer = Layer(name=name, extent=extent)
for blob in feature_blobs:
feat = _decode_feature(blob, keys, values)
if feat is not None:
layer.features.append(feat)
return layer
def decode_tile(data: bytes) -> list[Layer]:
"""Decode a vector tile (optionally gzip-compressed) into its layers.
Args:
data: Raw ``.pbf`` bytes, gzip-compressed or not.
Returns:
The decoded layers, in the order they appear in the tile. An empty or unparseable
tile yields an empty list rather than raising.
"""
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
r = _Reader(data)
layers: list[Layer] = []
while not r.eof():
field_no, wire = r.tag()
if field_no == 3 and wire == 2: # Tile.layers
layers.append(_decode_layer(r.blob()))
else:
r.skip(wire)
return layers
+10 -3
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_meta (
@@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS observations (
node TEXT,
name TEXT,
kind TEXT NOT NULL DEFAULT 'advert',
node_type INTEGER, -- advert type (repeater/chat/room/...), when carried
snr REAL,
rssi REAL,
lat REAL,
@@ -149,8 +150,8 @@ def _migrate(conn: sqlite3.Connection) -> None:
added columns are created here too (not in ``_SCHEMA``), so ``executescript`` never
references a column an older database hasn't grown yet.
"""
columns = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")}
if "channel_id" not in columns:
message_cols = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")}
if "channel_id" not in message_cols:
# v3 -> v4: channel history moved from being keyed by slot index to a
# slot-independent channel identity. Existing rows are backfilled lazily at runtime
# (see Repository.backfill_channel_ids) once the device's channels can be read.
@@ -158,3 +159,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_channel_id ON messages(channel_id)"
)
observation_cols = {row["name"] for row in conn.execute("PRAGMA table_info(observations)")}
if "node_type" not in observation_cols:
# v4 -> v5: observations gained the transmitting node's advert type, so the map can
# tell repeaters from leaf nodes. Older rows simply carry NULL (type unknown).
conn.execute("ALTER TABLE observations ADD COLUMN node_type INTEGER")
+5 -3
View File
@@ -320,13 +320,14 @@ class Repository:
"""
self._conn.execute(
"INSERT INTO observations "
"(run_id, node, name, kind, snr, rssi, lat, lon, observed_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"(run_id, node, name, kind, node_type, snr, rssi, lat, lon, observed_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
run_id,
obs.node,
obs.name,
obs.kind,
obs.node_type,
obs.snr,
obs.rssi,
obs.lat,
@@ -361,7 +362,7 @@ class Repository:
Returns:
One :class:`HeardNode` per distinct node, ordered by most-recently heard.
"""
sql = "SELECT node, name, snr, rssi, lat, lon, observed_at FROM observations"
sql = "SELECT node, name, node_type, snr, rssi, lat, lon, observed_at FROM observations"
params: list[Any] = []
if since is not None:
sql += " WHERE observed_at >= ?"
@@ -373,6 +374,7 @@ class Repository:
obs = Observation(
node=row["node"],
name=row["name"],
node_type=row["node_type"],
snr=row["snr"],
rssi=row["rssi"],
lat=row["lat"],
+182
View File
@@ -0,0 +1,182 @@
"""Fetch and cache OpenStreetMap vector tiles for the terminal map.
This is the only networked part of the map. It resolves a tile source's (versioned) tile-URL
template from its TileJSON, fetches ``.pbf`` vector tiles over HTTPS, and caches them on disk
so panning back over ground you've seen is instant and later sessions work offline. Decoding
is delegated to the pure :mod:`meshtools.core.mvt`.
The default source is **OpenFreeMap** (openfreemap.org) — full-planet OpenStreetMap vector
tiles, free and requiring no API key. Everything here is best-effort: with no network and no
cached tiles the loader simply returns ``None`` and the map falls back to plotting nodes on a
blank grid, never raising into the UI.
Fetches are blocking (stdlib ``urllib``); callers on an event loop should run
:meth:`BasemapSource.load_tile` via ``asyncio.to_thread`` so the UI stays responsive.
"""
from __future__ import annotations
import json
import logging
import urllib.request
from pathlib import Path
from typing import Optional
from ..core.mvt import Layer, decode_tile
#: OpenFreeMap planet TileJSON — its ``tiles`` array holds the current versioned template.
DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
#: Sent on every request; identifies the client per common tile-usage etiquette.
_USER_AGENT = "MeshTools/0.1 (+https://github.com/; mesh node map)"
#: Fallback max tile zoom if the TileJSON doesn't declare one (OpenFreeMap serves 14).
_DEFAULT_MAX_ZOOM = 14
_log = logging.getLogger(__name__)
class BasemapSource:
"""A cached OpenStreetMap vector-tile source.
Attributes:
cache_dir: Directory under which tiles and the resolved TileJSON are cached.
"""
def __init__(
self,
cache_dir: Path,
*,
tilejson_url: str = DEFAULT_TILEJSON_URL,
timeout: float = 12.0,
) -> None:
"""Open a tile source backed by an on-disk cache.
Args:
cache_dir: Where fetched tiles and TileJSON metadata are stored.
tilejson_url: URL of the source's TileJSON document.
timeout: Per-request network timeout in seconds.
"""
self.cache_dir = cache_dir
self._tilejson_url = tilejson_url
self._timeout = timeout
self._template: Optional[str] = None
self._max_zoom: Optional[int] = None
self._resolved = False # whether we've tried (success or offline) this session
# -- metadata ---------------------------------------------------------------
def _tilejson_path(self) -> Path:
return self.cache_dir / "tilejson.json"
def _resolve(self) -> None:
"""Resolve and cache the tile-URL template and max zoom (best-effort, once)."""
if self._resolved:
return
self._resolved = True
# Prefer a freshly fetched TileJSON (the template is versioned and rotates), but fall
# back to a previously cached copy so a session started offline can still use disk
# tiles and even re-fetch if the template is still valid.
data = self._http_get(self._tilejson_url)
if data is not None:
try:
self._tilejson_path().parent.mkdir(parents=True, exist_ok=True)
self._tilejson_path().write_bytes(data)
except OSError: # pragma: no cover - cache write failure is non-fatal
pass
if data is None and self._tilejson_path().exists():
try:
data = self._tilejson_path().read_bytes()
except OSError: # pragma: no cover
data = None
if data is None:
return
try:
doc = json.loads(data)
tiles = doc.get("tiles") or []
if tiles:
self._template = str(tiles[0])
self._max_zoom = int(doc.get("maxzoom", _DEFAULT_MAX_ZOOM))
except (ValueError, TypeError): # pragma: no cover - malformed TileJSON
pass
@property
def max_zoom(self) -> int:
"""The highest zoom the source serves (resolved lazily; sensible default offline)."""
self._resolve()
return self._max_zoom if self._max_zoom is not None else _DEFAULT_MAX_ZOOM
@property
def available(self) -> bool:
"""Whether a tile-URL template is known (network reachable, or one was cached)."""
self._resolve()
return self._template is not None
# -- tiles ------------------------------------------------------------------
def _tile_path(self, z: int, x: int, y: int) -> Path:
return self.cache_dir / "tiles" / str(z) / str(x) / f"{y}.pbf"
def load_tile(self, z: int, x: int, y: int) -> Optional[list[Layer]]:
"""Return the decoded layers for a tile, from cache or the network.
Args:
z: Tile zoom.
x: Tile x index.
y: Tile y index.
Returns:
The decoded layers, or ``None`` if the tile is unavailable (offline and
uncached, or a genuinely empty/missing tile).
"""
path = self._tile_path(z, x, y)
raw = self._read_cached(path)
if raw is None:
raw = self._fetch_tile(z, x, y)
if raw is not None:
self._write_cached(path, raw)
if not raw:
return None
try:
return decode_tile(raw)
except Exception as exc: # noqa: BLE001 - a corrupt tile must not crash the map
_log.debug("failed to decode tile %s/%s/%s: %s", z, x, y, exc)
return None
def _fetch_tile(self, z: int, x: int, y: int) -> Optional[bytes]:
"""Fetch a tile's raw bytes from the network, or ``None`` if unavailable."""
self._resolve()
if self._template is None:
return None
url = (
self._template.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
)
data = self._http_get(url)
# A 404/empty response is a legitimately empty tile; cache an empty marker so we
# don't re-request it every repaint.
return data if data is not None else b""
@staticmethod
def _read_cached(path: Path) -> Optional[bytes]:
try:
return path.read_bytes() if path.exists() else None
except OSError: # pragma: no cover
return None
@staticmethod
def _write_cached(path: Path, raw: bytes) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(raw)
except OSError: # pragma: no cover - cache write failure is non-fatal
pass
def _http_get(self, url: str) -> Optional[bytes]:
"""GET a URL, returning the body, or ``None`` on any network/HTTP failure."""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return resp.read()
except Exception as exc: # noqa: BLE001 - offline / 404 / timeout are all "no tile"
_log.debug("tile fetch failed for %s: %s", url, exc)
return None
+227
View File
@@ -0,0 +1,227 @@
"""The ``map`` tool: plot the mesh's located nodes on an OpenStreetMap terminal map.
Nodes that share their location in adverts are collected from the passive-monitor history
(:meth:`~meshtools.persistence.repository.Repository.heard_nodes`), our own node is added when
its position is known, and everything is drawn over a real street basemap rendered as Unicode
braille (streets, rivers, place names — the same terminal-map idea as ``mapscii``).
In the menu it opens a full-screen, pannable/zoomable map (see
:mod:`meshtools.ui.map_screen`). On the CLI it prints a one-shot render fitted to the nodes.
Repeaters are prioritised over ordinary nodes: a distinct marker, drawn on top, listed first.
The basemap is best-effort — with no network (and no cached tiles) the nodes are plotted on a
blank grid instead, and the tool still works offline.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional
import typer
from rich.console import Group
from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..core.models import NODE_TYPE_REPEATER
from .base import Tool, ToolResult, register
if TYPE_CHECKING:
from ..ui.map_render import MapMarker
@register
class MapTool(Tool):
"""Show the mesh's location-sharing nodes on a braille OpenStreetMap map."""
name = "map"
help = "Show mesh nodes on a street map (pannable; repeaters highlighted)."
category = "Messaging"
order = 30
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Gather located nodes and open the interactive map, or render a static one.
Args:
ctx: Shared application context.
params: Optional ``static`` (force one-shot render), ``width``, ``zoom``,
``basemap`` (set ``False`` to skip fetching tiles).
Returns:
A :class:`ToolResult` summarizing how many nodes were plotted.
"""
from ..ui.surface import TuiUi
markers = await self._gather(ctx)
if not markers:
ctx.ui.note(
"[muted]no nodes have shared a location yet — turn on the passive "
"monitor and wait for located adverts[/muted]"
)
return ToolResult(summary={"located": 0})
interactive = isinstance(ctx.ui, TuiUi) and not params.get("static")
if interactive:
from ..ui.map_screen import open_map
await open_map(ctx, markers)
else:
await self._render_static(ctx, markers, params)
repeaters = sum(1 for m in markers if m.is_repeater and not m.is_self)
self_located = any(m.is_self for m in markers)
return ToolResult(
summary={
"located": len(markers),
"repeaters": repeaters,
"nodes": len(markers) - repeaters - (1 if self_located else 0),
"self_located": self_located,
},
message=None if interactive else (
f"[ok]✓[/ok] mapped [brand]{len(markers)}[/brand] located nodes "
f"([accent]{repeaters}[/accent] repeaters)"
),
)
# -- marker gathering -------------------------------------------------------
async def _gather(self, ctx: AppContext) -> list["MapMarker"]:
"""Collect located heard nodes plus our own node (when its position is known)."""
from ..ui.map_render import MapMarker
markers: list[MapMarker] = []
for node in ctx.repo.heard_nodes():
if not node.has_location:
continue
detail = f"{node.count} pkts"
if node.median_snr is not None:
detail += f" · {node.median_snr:+.1f} dB"
markers.append(
MapMarker(
label=node.name or node.node or "?",
lat=float(node.lat),
lon=float(node.lon),
is_repeater=node.is_repeater,
detail=detail,
)
)
self_marker = await self._self_marker(ctx)
if self_marker is not None:
markers.append(self_marker)
return markers
@staticmethod
async def _self_marker(ctx: AppContext) -> Optional["MapMarker"]:
"""Build a marker for our own node from the device, if its location is known."""
from ..ui.map_render import MapMarker
try:
info = await (await ctx.device()).get_self_info()
except Exception: # noqa: BLE001 - the map is useful without our own position
return None
lat, lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
if lat is None or lon is None or (abs(lat) < 1e-6 and abs(lon) < 1e-6):
return None # a device with no fix reports 0/0
return MapMarker(
label=str(info.get("name") or "this node"),
lat=lat,
lon=lon,
is_repeater=info.get("adv_type") == NODE_TYPE_REPEATER,
is_self=True,
)
# -- static (CLI) render ----------------------------------------------------
async def _render_static(
self, ctx: AppContext, markers: list["MapMarker"], params: dict[str, Any]
) -> None:
"""Fetch tiles synchronously and print a one-shot map fitted to the nodes."""
import asyncio
from ..core.geo import Viewport
from ..ui.map_render import render_map
from ..ui.map_screen import basemap_source
cell_w = int(params.get("width") or min(ctx.console.size.width - 2, 160))
cell_h = max(12, cell_w * 4 // 13) # keep roughly the terminal's aspect
dot_w, dot_h = cell_w * 2, cell_h * 4
source = basemap_source(ctx)
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
coords = [(m.lat, m.lon) for m in markers]
if params.get("zoom") is not None:
from ..core.geo import BBox
center = BBox.around(coords).center
vp = Viewport(center[0], center[1], int(params["zoom"]), dot_w, dot_h)
else:
vp = Viewport.fit(coords, dot_w, dot_h, max_zoom=max_zoom)
tiles = {}
if params.get("basemap", True):
for tile in vp.tiles(max_zoom):
tiles[tile] = await asyncio.to_thread(source.load_tile, *tile)
lines = render_map(vp, tiles, markers)
body = Text.from_ansi("\n".join(lines))
ctx.ui.show(Group(body, Text(""), _legend(markers)))
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``map`` subcommand.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _map(
width: Optional[int] = typer.Option(
None, "--width", "-w", help="Map width in character cells."
),
zoom: Optional[int] = typer.Option(
None, "--zoom", "-z", help="Fixed zoom level (omit to fit all nodes)."
),
basemap: bool = typer.Option(
True, "--basemap/--no-basemap", help="Draw the OpenStreetMap street basemap."
),
) -> None:
params: dict[str, Any] = {"static": True, "basemap": basemap}
if width is not None:
params["width"] = width
if zoom is not None:
params["zoom"] = zoom
run_tool_command(self, params)
def _legend(markers: list["MapMarker"]) -> Table:
"""A compact legend: self → repeaters → leaf nodes, with coordinates and detail."""
from ..ui.map_render import _NODE, _REPEATER, _SELF
table = Table(box=None, padding=(0, 2, 0, 0), expand=False)
table.add_column("")
table.add_column("node")
table.add_column("type")
table.add_column("coords", justify="right")
table.add_column("seen")
ordered = sorted(markers, key=lambda m: -m._rank())
for m in ordered:
glyph, color = _SELF if m.is_self else (_REPEATER if m.is_repeater else _NODE)
kind = "you" if m.is_self else ("repeater" if m.is_repeater else "node")
table.add_row(
Text(glyph, style=color),
Text(m.label, style=color if m.is_self else ""),
Text(kind, style="muted"),
Text(f"{m.lat:.4f}, {m.lon:.4f}", style="muted"),
Text(m.detail, style="muted"),
)
return table
def _as_float(value: object) -> Optional[float]:
"""Best-effort float conversion, returning ``None`` on missing/garbage values."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
+313
View File
@@ -0,0 +1,313 @@
"""Compose a rendered map frame: basemap geometry, labels, and mesh nodes.
Given a :class:`~meshtools.core.geo.Viewport` and the decoded vector tiles covering it, this
projects every street, river, water body and place label onto a :class:`MapCanvas`, then
overlays the mesh nodes with their names on top. It is pure and synchronous — tile *fetching*
happens elsewhere (:mod:`meshtools.services.basemap`) — so both the interactive screen and
the one-shot CLI render call the same code.
Colours target a dark terminal (the app theme): warm roads, grey minor streets, blue water
and rivers, faint green parks. Because a cell shows one colour, draw priorities keep the
important feature visible where things overlap (rivers over water, major roads over minor).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from ..core.geo import Viewport
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from .mapcanvas import RGB, MapCanvas, parse_hex
# -- node markers -------------------------------------------------------------
@dataclass(slots=True)
class MapMarker:
"""One mesh node to overlay on the map.
Attributes:
label: Node name shown beside the marker.
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
is_repeater: Whether the node is a repeater (prioritised marker).
is_self: Whether this is our own node (highlighted).
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
"""
label: str
lat: float
lon: float
is_repeater: bool = False
is_self: bool = False
detail: str = ""
def _rank(self) -> int:
"""Draw order: self on top of repeaters on top of leaf nodes."""
return 2 if self.is_self else (1 if self.is_repeater else 0)
_SELF = ("★", "#ffffff")
_REPEATER = ("▲", "#ff5d73")
_NODE = ("●", "#22d3ee")
# -- basemap styling ----------------------------------------------------------
# Road class -> (colour, priority). Higher priority wins a shared cell.
_ROAD_STYLE: dict[str, tuple[str, int]] = {
"motorway": ("#f2a13d", 27),
"trunk": ("#f2a13d", 26),
"primary": ("#e8b24a", 25),
"secondary": ("#d7c257", 24),
"tertiary": ("#a7adb8", 23),
"minor": ("#828894", 21),
"unclassified": ("#828894", 21),
"residential": ("#828894", 21),
"living_street": ("#767c88", 20),
"service": ("#5f646e", 19),
"pedestrian": ("#5a5f69", 15),
"path": ("#4b5560", 12),
"track": ("#4b5560", 12),
"footway": ("#454e58", 11),
"cycleway": ("#455864", 12),
}
_ROAD_DEFAULT = ("#5f646e", 18)
_RAIL = ("#8a8f98", 22)
# Waterway class -> (colour, priority).
_WATERWAY_STYLE: dict[str, tuple[str, int]] = {
"river": ("#49b0ec", 30),
"canal": ("#49b0ec", 30),
"stream": ("#3f8fbf", 29),
"ditch": ("#3a7ba6", 28),
"drain": ("#3a7ba6", 28),
}
_WATER_FILL = ("#153b56", 6)
_GREEN_FILL = ("#173a29", 4)
_GREEN_CLASSES = {"wood", "forest", "grass", "park", "meadow", "scrub", "wetland", "cemetery"}
# Label styling per place class: (colour, bold, rank, min_display_zoom). Lower rank places
# claim screen space first; min zoom hides the fine detail until you zoom in.
_PLACE_STYLE: dict[str, tuple[str, bool, int, int]] = {
"city": ("#f8fafc", True, 0, 0),
"town": ("#e6ebf2", True, 1, 9),
"village": ("#cdd6e2", False, 2, 12),
"suburb": ("#9fb0c4", False, 3, 12),
"quarter": ("#94a5ba", False, 4, 13),
"neighbourhood": ("#8496ab", False, 5, 13),
"hamlet": ("#8496ab", False, 6, 14),
}
_WATER_LABEL = ("#7dd3fc", False, 2)
_STREET_LABEL = ("#9aa0aa", False, 7)
@dataclass(slots=True)
class _Label:
"""A pending label placement candidate."""
rank: int
x: float
y: float
text: str
color: RGB
bold: bool
min_zoom: int = 0
@dataclass(slots=True)
class _Frame:
"""Working state while composing one frame."""
canvas: MapCanvas
viewport: Viewport
labels: list[_Label] = field(default_factory=list)
def render_map(
viewport: Viewport,
tiles: dict[tuple[int, int, int], Optional[list[Layer]]],
markers: list[MapMarker],
*,
max_labels: int = 80,
) -> list[str]:
"""Render a full map frame to truecolour ANSI lines.
Args:
viewport: The view to render (its dot size sets the canvas size).
tiles: Decoded layers keyed by ``(z, x, y)``; ``None`` values are pending/absent.
markers: Mesh nodes to overlay.
max_labels: Cap on basemap labels placed, to keep the map readable.
Returns:
One ANSI string per row, ready for the TUI frame or the console.
"""
canvas = MapCanvas(viewport.dot_w // 2, viewport.dot_h // 4)
frame = _Frame(canvas=canvas, viewport=viewport)
for (z, x, y), layers in tiles.items():
if layers:
_draw_tile(frame, layers, z, x, y)
# Nodes reserve their cells first so basemap labels route around them.
_draw_nodes(canvas, viewport, markers)
# Then place basemap labels by importance, honouring the zoom gate and collisions.
placed = 0
for label in sorted(frame.labels, key=lambda l: l.rank):
if placed >= max_labels:
break
if viewport.zoom < label.min_zoom:
continue
if canvas.place_label(label.x, label.y, label.text, label.color, bold=label.bold):
placed += 1
return canvas.to_ansi_lines()
def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> None:
"""Draw one decoded tile's geometry and collect its label candidates."""
vp = frame.canvas
by_name = {layer.name: layer for layer in layers}
def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]:
return [
frame.viewport.feature_to_dot(x, y, z, extent, lx, ly) for lx, ly in ring
]
# Fills first (water, green space) so lines and labels sit on top.
for name in ("water", "landcover", "landuse", "park"):
layer = by_name.get(name)
if layer is None:
continue
for feat in layer.features:
if feat.geom_type != GEOM_POLYGON:
continue
if name == "water":
color, prio = _WATER_FILL
elif str(feat.get("class") or feat.get("subclass")) in _GREEN_CLASSES:
color, prio = _GREEN_FILL
else:
continue
vp.fill_polygon(
[project(r, layer.extent) for r in feat.rings], parse_hex(color), prio
)
# Waterways (rivers/streams) as lines.
waterway = by_name.get("waterway")
if waterway is not None:
for feat in waterway.features:
style = _WATERWAY_STYLE.get(str(feat.get("class")))
if style is None or feat.geom_type != GEOM_LINE:
continue
color, prio = style
for ring in feat.rings:
if len(ring) >= 2:
vp.draw_line(project(ring, waterway.extent), parse_hex(color), prio)
if feat.name:
_add_line_label(frame, feat.rings, waterway.extent, z, x, y, feat.name, _WATER_LABEL)
# Roads / rail.
transportation = by_name.get("transportation")
if transportation is not None:
for feat in transportation.features:
if feat.geom_type != GEOM_LINE:
continue
cls = str(feat.get("class") or "")
if cls in ("rail", "transit"):
color, prio = _RAIL
else:
color, prio = _ROAD_STYLE.get(cls, _ROAD_DEFAULT)
rgb = parse_hex(color)
for ring in feat.rings:
if len(ring) >= 2:
vp.draw_line(project(ring, transportation.extent), rgb, prio)
# Boundaries (admin) as a faint hint.
boundary = by_name.get("boundary")
if boundary is not None:
for feat in boundary.features:
if feat.geom_type != GEOM_LINE:
continue
try:
if int(feat.get("admin_level", 99)) > 6:
continue
except (TypeError, ValueError):
continue
for ring in feat.rings:
if len(ring) >= 2:
vp.draw_line(project(ring, boundary.extent), parse_hex("#6d5f88"), 14)
# Street-name labels (only kick in at high zoom via the label's min_zoom gate).
tname = by_name.get("transportation_name")
if tname is not None:
color, bold, rank = _STREET_LABEL
for feat in tname.features:
if feat.name and feat.rings:
_add_line_label(
frame, feat.rings, tname.extent, z, x, y, feat.name,
(color, bold, rank), min_zoom=15,
)
# Place labels.
place = by_name.get("place")
if place is not None:
for feat in place.features:
if not feat.name or not feat.rings or not feat.rings[0]:
continue
style = _PLACE_STYLE.get(str(feat.get("class")))
if style is None:
continue
color, bold, rank, min_zoom = style
lx, ly = feat.rings[0][0]
dx, dy = frame.viewport.feature_to_dot(x, y, z, place.extent, lx, ly)
frame.labels.append(
_Label(rank, dx, dy, feat.name, parse_hex(color), bold, min_zoom)
)
# Water-body names.
water_name = by_name.get("water_name")
if water_name is not None:
color, bold, rank = _WATER_LABEL
for feat in water_name.features:
if feat.name and feat.rings and feat.rings[0]:
lx, ly = feat.rings[0][0]
dx, dy = frame.viewport.feature_to_dot(x, y, z, water_name.extent, lx, ly)
frame.labels.append(_Label(rank, dx, dy, feat.name, parse_hex(color), bold, 8))
def _add_line_label(
frame: _Frame,
rings: list[list[tuple[int, int]]],
extent: int,
z: int,
x: int,
y: int,
text: str,
style: tuple[str, bool, int],
*,
min_zoom: int = 0,
) -> None:
"""Queue a label at the midpoint of a line feature's longest part."""
longest = max(rings, key=len)
lx, ly = longest[len(longest) // 2]
dx, dy = frame.viewport.feature_to_dot(x, y, z, extent, lx, ly)
color, bold, rank = style
frame.labels.append(_Label(rank, dx, dy, text, parse_hex(color), bold, min_zoom))
def _draw_nodes(canvas: MapCanvas, viewport: Viewport, markers: list[MapMarker]) -> None:
"""Overlay mesh nodes (lowest priority first, so self/repeaters land on top)."""
for marker in sorted(markers, key=lambda m: m._rank()):
x, y = viewport.lonlat_to_dot(marker.lat, marker.lon)
ix, iy = int(round(x)), int(round(y))
if not (0 <= ix < viewport.dot_w and 0 <= iy < viewport.dot_h):
continue
if marker.is_self:
glyph, color = _SELF
elif marker.is_repeater:
glyph, color = _REPEATER
else:
glyph, color = _NODE
canvas.marker(ix, iy, glyph, parse_hex(color), label=marker.label)
+217
View File
@@ -0,0 +1,217 @@
"""The interactive full-screen map: a pannable, zoomable slippy map in the terminal.
This drives the braille street map inside the TUI. It owns a :class:`~meshtools.core.geo.
Viewport` over the mesh's nodes, fetches the vector tiles covering it in the background (so
the UI never blocks on the network), and redraws via :func:`~meshtools.ui.map_render.
render_map`. Keys:
* ``w`` / ``a`` / ``s`` / ``d`` (or the arrow keys) pan north / west / south / east,
* ``=`` / ``+`` zoom in, ``-`` / ``_`` zoom out,
* ``r`` recenters and refits to the nodes,
* ``Esc`` / ``q`` leaves the map.
With no network (and no cached tiles) the basemap is simply absent and nodes are plotted on a
blank grid — the map still works, it just has no streets.
"""
from __future__ import annotations
import asyncio
import math
from typing import TYPE_CHECKING, Optional
from ..core.geo import EARTH_RADIUS_KM, Viewport
from ..core.mvt import Layer
from ..services.basemap import BasemapSource
from .map_render import MapMarker, render_map
from .tui.screen import Screen
if TYPE_CHECKING:
from ..context import AppContext
#: Fraction of the view a single pan keypress moves.
_PAN_STEP = 0.30
#: How far past the tile source's max zoom the display may go (lower tiles are magnified).
_OVERZOOM = 2
class MapScreen(Screen):
"""A full-screen, keyboard-driven map of the mesh's located nodes over an OSM basemap."""
floating = False
def __init__(
self,
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
markers: list[MapMarker],
source: BasemapSource,
max_tile_zoom: int,
) -> None:
"""Create the map screen.
Args:
session: The running TUI session (for size + repaint scheduling).
markers: The located mesh nodes to plot (must be non-empty).
source: The vector-tile source (already resolved/warmed).
max_tile_zoom: The source's max zoom, captured off the event loop at open time.
"""
super().__init__()
self.title = "mesh map"
self._session = session
self._markers = markers
self._source = source
self._max_tile_zoom = max_tile_zoom
self._viewport: Optional[Viewport] = None
self._size: tuple[int, int] = (0, 0) # (dot_w, dot_h) the viewport is built for
# Decoded tiles keyed by (z, x, y); a stored ``None`` means "fetched, empty/absent".
self._tiles: dict[tuple[int, int, int], Optional[list[Layer]]] = {}
self._pending: set[tuple[int, int, int]] = set()
# --- rendering -----------------------------------------------------------
@property
def footer_hint(self) -> str: # type: ignore[override]
"""Key hints plus a live tile-loading indicator."""
base = "wasd/↑↓←→ pan · +/- zoom · r reset · Esc back"
if self._pending:
return f"{base} · [muted]loading {len(self._pending)} tiles…[/muted]"
if not self._source.available:
return f"{base} · [warn]offline — no basemap[/warn]"
return base
def render_body(self, width: int) -> list[str]:
"""Build (or resize) the viewport, ensure its tiles, and render the frame."""
_, cell_h = self._session.base_body_size()
cell_w = width
dot_w, dot_h = cell_w * 2, cell_h * 4
if self._viewport is None:
self._viewport = Viewport.fit(
[(m.lat, m.lon) for m in self._markers],
dot_w,
dot_h,
max_zoom=self._max_tile_zoom,
)
self._size = (dot_w, dot_h)
elif self._size != (dot_w, dot_h):
self._viewport = self._viewport.resized(dot_w, dot_h)
self._size = (dot_w, dot_h)
self._ensure_tiles(self._viewport)
self.title = self._title(self._viewport)
tiles = {t: self._tiles.get(t) for t in self._viewport.tiles(self._max_tile_zoom)}
return render_map(self._viewport, tiles, self._markers)
def _title(self, vp: Viewport) -> str:
"""A compact status title: zoom, node count, and scale (metres per dot)."""
# Ground metres per braille dot at the view centre, for a rough sense of scale.
m_per_dot = (
2 * math.pi * EARTH_RADIUS_KM * 1000
* math.cos(math.radians(vp.center_lat))
/ (256 * (2**vp.zoom))
)
scale = f"{m_per_dot * vp.dot_w:.0f} m across" if m_per_dot * vp.dot_w < 1000 else \
f"{m_per_dot * vp.dot_w / 1000:.1f} km across"
return f"mesh map · z{vp.zoom} · {len(self._markers)} nodes · {scale}"
# --- tiles ---------------------------------------------------------------
def _ensure_tiles(self, vp: Viewport) -> None:
"""Schedule background fetches for any visible tiles not yet loaded or pending."""
if not self._source.available: # offline (resolved at open time) — nodes only
return
for t in vp.tiles(self._max_tile_zoom):
if t in self._tiles or t in self._pending:
continue
self._pending.add(t)
try:
asyncio.ensure_future(self._load(t))
except RuntimeError: # pragma: no cover - no running loop (non-interactive)
self._pending.discard(t)
async def _load(self, t: tuple[int, int, int]) -> None:
"""Fetch+decode one tile off the event loop, then repaint."""
try:
layers = await asyncio.to_thread(self._source.load_tile, *t)
except Exception: # noqa: BLE001 - a failed tile is just an absent one
layers = None
self._tiles[t] = layers
self._pending.discard(t)
self._session.invalidate()
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Pan, zoom, reset, or exit in response to a normalized key action."""
vp = self._viewport
if action == "escape":
self.resolve(None)
return
if vp is None:
return
if action == "up":
self._viewport = vp.panned(0, -_PAN_STEP)
elif action == "down":
self._viewport = vp.panned(0, _PAN_STEP)
elif action == "left":
self._viewport = vp.panned(-_PAN_STEP, 0)
elif action == "right":
self._viewport = vp.panned(_PAN_STEP, 0)
elif action == "text":
self._handle_key(data, vp)
def _handle_key(self, key: str, vp: Viewport) -> None:
"""Handle a printable-key action (pan/zoom/reset/quit)."""
key = key.lower()
if key == "w":
self._viewport = vp.panned(0, -_PAN_STEP)
elif key == "s":
self._viewport = vp.panned(0, _PAN_STEP)
elif key == "a":
self._viewport = vp.panned(-_PAN_STEP, 0)
elif key == "d":
self._viewport = vp.panned(_PAN_STEP, 0)
elif key in ("=", "+"):
self._viewport = vp.zoomed(1, max_zoom=self._max_tile_zoom + _OVERZOOM)
elif key in ("-", "_"):
self._viewport = vp.zoomed(-1)
elif key == "r":
self._viewport = Viewport.fit(
[(m.lat, m.lon) for m in self._markers],
vp.dot_w,
vp.dot_h,
max_zoom=self._max_tile_zoom,
)
elif key == "q":
self.resolve(None)
def basemap_source(ctx: "AppContext") -> BasemapSource:
"""Build the vector-tile source backed by the app's on-disk tile cache."""
return BasemapSource(ctx.settings.config_dir / "tilecache")
async def open_map(ctx: "AppContext", markers: list[MapMarker]) -> None:
"""Open the interactive full-screen map over ``markers`` and run until dismissed.
Warms the tile source off the event loop (so the first paint doesn't block on the
network), then pushes the :class:`MapScreen` and awaits its dismissal.
Args:
ctx: Shared application context (must be in the interactive menu).
markers: The located mesh nodes to plot (non-empty).
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the interactive map is only available in the menu")
session = ctx.ui.session
source = basemap_source(ctx)
# Resolve the tile template/zoom in a worker thread so the UI thread never blocks.
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
screen = MapScreen(session, markers, source, max_zoom)
await session.run_screen(screen)
+252
View File
@@ -0,0 +1,252 @@
"""A colored Unicode-braille canvas for the terminal map.
Each character cell holds a 2×4 grid of braille dots, so the drawable resolution is twice
the columns by four times the rows. Streets, rivers and water are rasterized as braille
*dots*; place names, street names and node labels are written as *text* over whole cells;
node markers are single glyphs on top.
A terminal cell can only show one colour, so each cell keeps the colour of the
highest-**priority** feature whose dots fall in it (a river drawn over water keeps the river's
blue). Text and markers form a separate overlay that always wins the cell, with greedy
collision avoidance so labels never overprint each other. The canvas renders straight to
truecolour ANSI lines, which is exactly what the TUI frame consumes.
"""
from __future__ import annotations
from typing import Optional
#: Unicode braille pattern base; add a dot bitmask to get the glyph.
_BRAILLE_BASE = 0x2800
#: Dot bit for each (col, row) within a cell — the Unicode braille standard layout.
_DOT_BITS = (
(0x01, 0x02, 0x04, 0x40), # left column, rows 0..3
(0x08, 0x10, 0x20, 0x80), # right column, rows 0..3
)
RGB = tuple[int, int, int]
def parse_hex(color: str) -> RGB:
"""Convert ``"#rrggbb"`` (or ``"rrggbb"``) to an ``(r, g, b)`` tuple."""
c = color.lstrip("#")
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
class MapCanvas:
"""A colored braille raster with a text/marker overlay and label collision tracking."""
def __init__(self, cell_w: int, cell_h: int) -> None:
"""Create a blank canvas.
Args:
cell_w: Width in character cells.
cell_h: Height in character cells.
"""
self.cell_w = max(1, cell_w)
self.cell_h = max(1, cell_h)
self.dot_w = self.cell_w * 2
self.dot_h = self.cell_h * 4
self._bits = [[0] * self.cell_w for _ in range(self.cell_h)]
self._color: list[list[Optional[RGB]]] = [
[None] * self.cell_w for _ in range(self.cell_h)
]
self._prio = [[-1] * self.cell_w for _ in range(self.cell_h)]
# Overlay: (cx, cy) -> (char, rgb, bold). Occupied tracks cells claimed by labels /
# markers so later labels can avoid them.
self._overlay: dict[tuple[int, int], tuple[str, RGB, bool]] = {}
self._occupied: set[tuple[int, int]] = set()
# -- primitives -------------------------------------------------------------
def plot(self, x: int, y: int, color: RGB, priority: int) -> None:
"""Light the braille dot at dot ``(x, y)`` and colour its cell by ``priority``."""
if not (0 <= x < self.dot_w and 0 <= y < self.dot_h):
return
cx, cy = x >> 1, y >> 2
self._bits[cy][cx] |= _DOT_BITS[x & 1][y & 3]
if priority >= self._prio[cy][cx]:
self._prio[cy][cx] = priority
self._color[cy][cx] = color
def draw_line(
self, points: list[tuple[float, float]], color: RGB, priority: int
) -> None:
"""Rasterize a polyline through ``points`` (dot coordinates) as braille dots."""
for (x0, y0), (x1, y1) in zip(points, points[1:]):
self._segment(x0, y0, x1, y1, color, priority)
def _segment(
self, x0: float, y0: float, x1: float, y1: float, color: RGB, priority: int
) -> None:
"""Bresenham a single segment, skipping ones wholly off the canvas."""
# Quick reject: both endpoints beyond the same edge → nothing visible.
if (x0 < 0 and x1 < 0) or (x0 >= self.dot_w and x1 >= self.dot_w):
return
if (y0 < 0 and y1 < 0) or (y0 >= self.dot_h and y1 >= self.dot_h):
return
xa, ya, xb, yb = int(round(x0)), int(round(y0)), int(round(x1)), int(round(y1))
dx, dy = abs(xb - xa), -abs(yb - ya)
sx = 1 if xa < xb else -1
sy = 1 if ya < yb else -1
err = dx + dy
while True:
self.plot(xa, ya, color, priority)
if xa == xb and ya == yb:
break
e2 = 2 * err
if e2 >= dy:
err += dy
xa += sx
if e2 <= dx:
err += dx
ya += sy
def fill_polygon(
self, rings: list[list[tuple[float, float]]], color: RGB, priority: int
) -> None:
"""Even-odd scanline fill of a polygon (with holes) in dot space."""
edges: list[tuple[float, float, float, float]] = []
ys: list[float] = []
for ring in rings:
for (x0, y0), (x1, y1) in zip(ring, ring[1:]):
if y0 != y1:
edges.append((x0, y0, x1, y1))
ys.extend((y0, y1))
if not edges:
return
y_start = max(0, int(min(ys)))
y_end = min(self.dot_h - 1, int(max(ys)))
for y in range(y_start, y_end + 1):
yc = y + 0.5
xs: list[float] = []
for x0, y0, x1, y1 in edges:
if (y0 <= yc < y1) or (y1 <= yc < y0):
xs.append(x0 + (yc - y0) * (x1 - x0) / (y1 - y0))
xs.sort()
for i in range(0, len(xs) - 1, 2):
x_from = max(0, int(round(xs[i])))
x_to = min(self.dot_w - 1, int(round(xs[i + 1])))
for x in range(x_from, x_to + 1):
self.plot(x, y, color, priority)
# -- overlay (markers + labels) --------------------------------------------
def marker(
self,
x: int,
y: int,
glyph: str,
color: RGB,
*,
label: str = "",
label_color: Optional[RGB] = None,
) -> None:
"""Place a marker glyph at dot ``(x, y)`` with an optional label beside it.
Markers always draw (they are the point of the map). The label is placed to the
right when there's room, otherwise to the left, and its cells are reserved so
basemap labels avoid them.
"""
cx, cy = x >> 1, y >> 2
if not (0 <= cx < self.cell_w and 0 <= cy < self.cell_h):
return
self._overlay[(cx, cy)] = (glyph, color, True)
self._occupied.add((cx, cy))
if label:
lc = label_color or color
# Prefer the label to the right of the marker (one blank cell gap); fall back to
# the left when it would overflow — never overwriting the marker glyph itself.
if cx + 2 + len(label) <= self.cell_w:
self._place_run(cx + 2, cy, label, lc, bold=True)
else:
self._place_run(max(0, cx - 1 - len(label)), cy, label, lc, bold=True)
def place_label(
self, x: float, y: float, text: str, color: RGB, *, bold: bool = False
) -> bool:
"""Write a centered basemap label at dot ``(x, y)`` if it fits without collision.
Args:
x: Anchor dot x.
y: Anchor dot y.
text: Label text.
color: Text colour.
bold: Whether to embolden (used for the most important places).
Returns:
``True`` if placed, ``False`` if it fell off-canvas or overlapped existing text.
"""
cx = int(x) >> 1
cy = int(y) >> 2
start = cx - len(text) // 2
return self._place_run(start, cy, text, color, bold=bold, checked=True)
def _place_run(
self,
start_cx: int,
cy: int,
text: str,
color: RGB,
*,
bold: bool,
checked: bool = False,
) -> bool:
"""Write ``text`` starting at cell ``(start_cx, cy)``.
With ``checked`` the run is skipped entirely if any target cell (or a one-cell
horizontal margin) is already claimed or off-canvas; otherwise it is forced and
simply clipped to the canvas. Returns whether anything was placed.
"""
if not (0 <= cy < self.cell_h):
return False
cells = range(start_cx, start_cx + len(text))
if checked:
if start_cx < 0 or start_cx + len(text) > self.cell_w:
return False
margin = range(start_cx - 1, start_cx + len(text) + 1)
if any((mx, cy) in self._occupied for mx in margin):
return False
placed = False
for offset, ch in enumerate(text):
mx = start_cx + offset
if 0 <= mx < self.cell_w:
self._overlay[(mx, cy)] = (ch, color, bold)
self._occupied.add((mx, cy))
placed = True
return placed
# -- output -----------------------------------------------------------------
def to_ansi_lines(self) -> list[str]:
"""Render the canvas to one truecolour ANSI string per row."""
reset = "\x1b[0m"
lines: list[str] = []
for cy in range(self.cell_h):
parts: list[str] = []
cur: Optional[tuple[RGB, bool]] = None
for cx in range(self.cell_w):
overlay = self._overlay.get((cx, cy))
if overlay is not None:
ch, color, bold = overlay
elif self._bits[cy][cx]:
ch = chr(_BRAILLE_BASE + self._bits[cy][cx])
color = self._color[cy][cx] or (128, 128, 128)
bold = False
else:
if cur is not None:
parts.append(reset)
cur = None
parts.append(" ")
continue
style = (color, bold)
if style != cur:
r, g, b = color
parts.append(f"\x1b[0m\x1b[38;2;{r};{g};{b}m" + ("\x1b[1m" if bold else ""))
cur = style
parts.append(ch)
if cur is not None:
parts.append(reset)
lines.append("".join(parts))
return lines
+17
View File
@@ -251,6 +251,23 @@ class TuiSession:
size = self._app.output.get_size() # type: ignore[union-attr]
return max(20, size.columns), max(6, size.rows)
def base_body_size(self) -> tuple[int, int]:
"""Return the ``(width, height)`` in cells available to the base screen's body.
Mirrors the layout math in :func:`~meshtools.ui.tui.frame.compose_base` so a
full-screen screen (e.g. the map) can size its own content to fill the frame exactly,
without waiting a repaint to learn its height.
Returns:
The inner content width and the body viewport height, both in character cells.
"""
from .render import render_lines
cols, rows = self._size()
header_h = len(render_lines(self._header(), cols, no_wrap=True))
viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2)
return cols - 4, viewport
def _base_screen(self) -> Optional[Screen]:
"""The screen drawn as the background (parent of a floating dialog, else the top)."""
if not self._stack:
Binary file not shown.
+397
View File
@@ -0,0 +1,397 @@
"""Tests for the node map: MVT decoding, projection, the braille canvas, and the tool.
The vector-tile decoder and geometry are exercised against a real cached tile fixture
(``tests/fixtures/tile_14_4843_5861.mvt``, central Montréal) so the parsing is verified end
to end without the network; the projection, canvas, and compositor are pure; the tool runs
against the :class:`MockDevice` simulator with the basemap disabled (no network in tests).
"""
from __future__ import annotations
import io
import re
from datetime import timedelta
from pathlib import Path
import pytest
from rich.console import Console
from meshtools.core.geo import BBox, Viewport, haversine_km, lonlat_to_world, world_to_lonlat
from meshtools.core.models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
Observation,
utcnow,
)
from meshtools.core.mvt import GEOM_LINE, GEOM_POLYGON, decode_tile
from meshtools.tools.map import MapTool
from meshtools.ui.mapcanvas import MapCanvas, parse_hex
_FIXTURE = Path(__file__).parent / "fixtures" / "tile_14_4843_5861.mvt"
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _plain(lines: list[str]) -> str:
"""Strip ANSI colour from rendered lines for content assertions."""
return "\n".join(_ANSI.sub("", line) for line in lines)
# -- MVT decoding -------------------------------------------------------------
def test_decode_tile_reads_expected_layers() -> None:
"""The real fixture decodes into the OpenMapTiles layers the map relies on."""
layers = {layer.name: layer for layer in decode_tile(_FIXTURE.read_bytes())}
for expected in ("transportation", "transportation_name", "water", "waterway", "place"):
assert expected in layers, expected
assert layers["transportation"].extent == 4096
assert layers["transportation"].features # roads were decoded
def test_decode_tile_resolves_names_and_geometry() -> None:
"""Street and place features carry resolved names and non-trivial geometry."""
layers = {layer.name: layer for layer in decode_tile(_FIXTURE.read_bytes())}
streets = [f.name for f in layers["transportation_name"].features if f.name]
assert any("Rue" in s or "Avenue" in s or "Av." in s for s in streets)
places = {f.name: f.get("class") for f in layers["place"].features if f.name}
assert "Montréal" in places and places["Montréal"] == "city"
# A road is a line with at least two points; water is a polygon.
assert any(
f.geom_type == GEOM_LINE and len(f.rings[0]) >= 2
for f in layers["transportation"].features
)
assert any(f.geom_type == GEOM_POLYGON for f in layers["water"].features)
def test_decode_tile_handles_empty_input() -> None:
"""An empty byte string decodes to no layers rather than raising."""
assert decode_tile(b"") == []
# -- projection / viewport ----------------------------------------------------
def test_world_projection_round_trips() -> None:
"""lon/lat -> world pixels -> lon/lat recovers the original coordinate."""
for lat, lon, z in [(45.5, -73.6, 14), (0.0, 0.0, 3), (-33.9, 151.2, 12)]:
wx, wy = lonlat_to_world(lat, lon, z)
rlat, rlon = world_to_lonlat(wx, wy, z)
assert rlat == pytest.approx(lat, abs=1e-6)
assert rlon == pytest.approx(lon, abs=1e-6)
def test_haversine_known_distance() -> None:
"""One degree of longitude at the equator is ~111 km; identical points are 0."""
assert haversine_km(0.0, 0.0, 0.0, 1.0) == pytest.approx(111.19, abs=0.5)
assert haversine_km(45.0, -73.0, 45.0, -73.0) == 0.0
def test_viewport_center_projects_to_the_middle() -> None:
"""The viewport's centre lands at the middle dot; north is up."""
vp = Viewport(45.5, -73.6, 13, 200, 120)
cx, cy = vp.lonlat_to_dot(45.5, -73.6)
assert cx == pytest.approx(100, abs=0.5) and cy == pytest.approx(60, abs=0.5)
# A point due north projects above the centre (smaller y).
_, ny = vp.lonlat_to_dot(45.6, -73.6)
assert ny < cy
def test_viewport_fit_frames_all_points() -> None:
"""A fitted viewport is centred on the points and zoomed so they share a tile scale."""
pts = [(45.5019, -73.5674), (45.4768, -73.5990)]
vp = Viewport.fit(pts, 200, 120, max_zoom=14)
assert 8 <= vp.zoom <= 14
for lat, lon in pts:
x, y = vp.lonlat_to_dot(lat, lon)
assert 0 <= x < vp.dot_w and 0 <= y < vp.dot_h # every point is on-canvas
def test_viewport_zoom_and_pan() -> None:
"""Zooming and panning return new viewports; zoom clamps to its bounds."""
vp = Viewport(45.5, -73.6, 10, 200, 120)
assert vp.zoomed(2).zoom == 12
assert vp.zoomed(50, max_zoom=16).zoom == 16 # clamped
assert vp.zoomed(-50, min_zoom=3).zoom == 3
east = vp.panned(0.5, 0)
assert east.center_lon > vp.center_lon # panning east increases longitude
north = vp.panned(0, -0.5)
assert north.center_lat > vp.center_lat # panning north increases latitude
def test_viewport_tiles_cover_the_view() -> None:
"""The viewport reports the handful of tiles overlapping it at the tile zoom."""
vp = Viewport(45.5019, -73.5674, 14, 200, 120)
tiles = vp.tiles(14)
assert tiles and all(z == 14 for z, _, _ in tiles)
# The fixture tile for this centre must be among them.
assert (14, 4843, 5861) in tiles
# -- braille canvas -----------------------------------------------------------
def test_canvas_plots_dots_as_braille() -> None:
"""A single plotted dot renders as a braille glyph in the top-left cell."""
canvas = MapCanvas(4, 2)
canvas.plot(0, 0, (255, 0, 0), 1)
out = _plain(canvas.to_ansi_lines())
assert out.splitlines()[0][0] == "⠁" # braille dot-1
def test_canvas_priority_decides_cell_colour() -> None:
"""When dots from two features share a cell, the higher priority sets its colour."""
canvas = MapCanvas(1, 1)
canvas.plot(0, 0, (10, 20, 30), 1) # low priority
canvas.plot(1, 0, (200, 100, 50), 9) # high priority, same cell
ansi = "".join(canvas.to_ansi_lines())
assert "38;2;200;100;50" in ansi # the high-priority colour won
assert "38;2;10;20;30" not in ansi
def test_canvas_marker_and_label_do_not_overprint() -> None:
"""A marker keeps its glyph and places its label in a neighbouring cell."""
canvas = MapCanvas(20, 3)
canvas.marker(4, 4, "★", (255, 255, 255), label="me")
out = _plain(canvas.to_ansi_lines())
assert "★" in out and "me" in out
row = out.splitlines()[1]
assert "★me" not in row # a gap sits between the marker and its label
def test_canvas_label_collision_is_avoided() -> None:
"""A checked label is skipped when it would overlap an already-placed one."""
canvas = MapCanvas(20, 1)
assert canvas.place_label(10, 0, "First", (200, 200, 200))
assert not canvas.place_label(10, 0, "Second", (200, 200, 200)) # overlaps → skipped
def test_parse_hex() -> None:
"""Hex colours parse to RGB triples, with or without the leading hash."""
assert parse_hex("#38bdf8") == (0x38, 0xBD, 0xF8)
assert parse_hex("ffffff") == (255, 255, 255)
# -- compositor over a real tile ----------------------------------------------
def test_render_map_draws_basemap_streets_and_labels() -> None:
"""Rendering the real fixture yields braille geometry and a place label."""
from meshtools.ui.map_render import MapMarker, render_map
vp = Viewport(45.5019, -73.5674, 14, 180, 120)
layers = decode_tile(_FIXTURE.read_bytes())
tiles = {(14, 4843, 5861): layers}
markers = [MapMarker("Yagi", 45.5019, -73.5674, is_repeater=True)]
out = _plain(render_map(vp, tiles, markers))
assert any(0x2800 <= ord(ch) <= 0x28FF for ch in out) # braille was drawn
assert "▲" in out and "Yagi" in out # the repeater marker + label
assert "Montréal" in out # a place label from the tile
def test_render_map_prioritises_repeater_glyph() -> None:
"""A repeater and a node at the same spot resolve to the repeater's marker on top."""
from meshtools.ui.map_render import MapMarker, render_map
vp = Viewport(45.50, -73.57, 14, 60, 40)
markers = [
MapMarker("node", 45.50, -73.57),
MapMarker("rptr", 45.50, -73.57, is_repeater=True),
]
out = _plain(render_map(vp, {}, markers))
assert "▲" in out and "●" not in out
def test_render_map_works_without_basemap() -> None:
"""With no tiles the nodes still render on a blank grid (the offline fallback)."""
from meshtools.ui.map_render import MapMarker, render_map
vp = Viewport.fit([(45.5, -73.6), (45.4, -73.5)], 120, 80, max_zoom=14)
markers = [
MapMarker("A", 45.5, -73.6, is_self=True),
MapMarker("B", 45.4, -73.5, is_repeater=True),
]
out = _plain(render_map(vp, {}, markers))
assert "★" in out and "▲" in out
# -- basemap source (offline behaviour) ---------------------------------------
def test_basemap_source_offline_is_graceful(tmp_path: Path) -> None:
"""An unreachable source reports unavailable and returns no tiles, never raising."""
from meshtools.services.basemap import BasemapSource
src = BasemapSource(tmp_path / "cache", tilejson_url="http://127.0.0.1:1/none", timeout=0.2)
assert src.available is False
assert src.max_zoom == 14 # sensible default when the TileJSON can't be fetched
assert src.load_tile(14, 4843, 5861) is None
def test_basemap_source_reads_cached_tile(tmp_path: Path) -> None:
"""A tile already on disk is decoded without any network access."""
from meshtools.services.basemap import BasemapSource
cache = tmp_path / "cache"
dest = cache / "tiles" / "14" / "4843"
dest.mkdir(parents=True)
(dest / "5861.pbf").write_bytes(_FIXTURE.read_bytes())
src = BasemapSource(cache, tilejson_url="http://127.0.0.1:1/none", timeout=0.2)
layers = src.load_tile(14, 4843, 5861)
assert layers is not None
assert any(layer.name == "transportation" for layer in layers)
# -- the tool -----------------------------------------------------------------
@pytest.fixture()
def ctx(tmp_path: Path):
"""A mock-backed application context with the plain (console) UI surface."""
from meshtools.context import AppContext
from meshtools.core.admin_store import AdminStore
from meshtools.core.config import Settings
from meshtools.core.device_store import DeviceStore
from meshtools.persistence.repository import Repository
from meshtools.ui.theme import MESH_THEME
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "map.db")
context = AppContext(
console=Console(file=io.StringIO(), theme=MESH_THEME),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
yield context
context.repo.close()
def _seed_located_nodes(repo) -> None:
"""Record a repeater and a leaf node (each located) plus one node with no fix."""
run_id = repo.start_run("monitor", {})
now = utcnow()
repo.record_observation(
run_id,
Observation(node="a1", name="Yagi", node_type=NODE_TYPE_REPEATER, snr=6.0,
lat=45.55, lon=-73.55, observed_at=now),
)
repo.record_observation(
run_id,
Observation(node="d4", name="Alice", node_type=NODE_TYPE_CHAT, snr=3.0,
lat=45.40, lon=-73.60, observed_at=now + timedelta(seconds=1)),
)
repo.record_observation(
run_id, Observation(node="c3", name="Bot", node_type=NODE_TYPE_CHAT, snr=1.0)
)
async def test_map_tool_static_render_counts_repeaters(ctx) -> None:
"""The CLI path renders located nodes (basemap off) and reports the breakdown."""
_seed_located_nodes(ctx.repo)
result = await MapTool().run(ctx, {"static": True, "basemap": False})
assert result.summary["located"] == 2 # the mock companion has no fix (0/0)
assert result.summary["repeaters"] == 1
assert result.summary["nodes"] == 1
assert result.summary["self_located"] is False
# Something was rendered into the plain UI surface.
assert ctx.console.file.getvalue() != ""
async def test_map_tool_reports_nothing_to_plot(ctx) -> None:
"""With no located history the tool returns cleanly instead of rendering."""
result = await MapTool().run(ctx, {"static": True, "basemap": False})
assert result.summary == {"located": 0}
class _StubSession:
"""Minimal stand-in for :class:`TuiSession` for driving :class:`MapScreen`."""
def __init__(self, cols: int, rows: int) -> None:
self._cols, self._rows = cols, rows
self.invalidated = 0
def base_body_size(self) -> tuple[int, int]:
return self._cols, self._rows
def invalidate(self) -> None:
self.invalidated += 1
class _StubSource:
"""An always-offline tile source, so the screen renders nodes only (no network)."""
available = False
max_zoom = 14
def load_tile(self, z: int, x: int, y: int):
return None
def test_map_screen_renders_pans_zooms_and_resets() -> None:
"""The interactive screen fills its body, and wasd/zoom/reset move the viewport."""
from meshtools.core.geo import Viewport
from meshtools.ui.map_render import MapMarker
from meshtools.ui.map_screen import MapScreen
session = _StubSession(80, 24)
markers = [
MapMarker("A", 45.50, -73.60, is_repeater=True),
MapMarker("B", 45.40, -73.50),
]
screen = MapScreen(session, markers, _StubSource(), 14)
lines = screen.render_body(80)
assert len(lines) == 24 # fills the body height reported by the session
start = screen._viewport
screen.handle("text", "d") # pan east → longitude increases
assert screen._viewport.center_lon > start.center_lon
screen.handle("text", "w") # pan north → latitude increases
assert screen._viewport.center_lat > start.center_lat
z = screen._viewport.zoom
screen.handle("text", "=")
assert screen._viewport.zoom == z + 1
screen.handle("text", "-")
assert screen._viewport.zoom == z
screen.handle("text", "r") # reset refits to the nodes
refit = Viewport.fit([(m.lat, m.lon) for m in markers], start.dot_w, start.dot_h, max_zoom=14)
assert screen._viewport.zoom == refit.zoom
assert screen._viewport.center_lat == pytest.approx(refit.center_lat)
# Offline source: nodes still render and both markers are present.
assert "▲" in _plain(screen.render_body(80)) and "●" in _plain(screen.render_body(80))
assert "offline" in screen.footer_hint
def test_map_screen_escape_dismisses() -> None:
"""Esc resolves the screen's future with None (backs out to the menu)."""
import asyncio
from meshtools.ui.map_render import MapMarker
from meshtools.ui.map_screen import MapScreen
screen = MapScreen(_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14)
async def drive() -> object:
screen.future = asyncio.get_running_loop().create_future()
screen.handle("escape")
return await screen.future
assert asyncio.run(drive()) is None
def test_map_observation_round_trips_node_type(ctx) -> None:
"""A stored observation's advert type survives persistence and aggregation."""
run_id = ctx.repo.start_run("monitor", {})
ctx.repo.record_observation(
run_id,
Observation(node="a1", name="Yagi", node_type=NODE_TYPE_REPEATER, snr=6.0,
lat=45.5, lon=-73.5),
)
node = next(n for n in ctx.repo.heard_nodes() if n.node == "a1")
assert node.is_repeater and node.node_type == NODE_TYPE_REPEATER