Files
MeshTerm/meshtools/tools/map.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

228 lines
8.6 KiB
Python

"""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