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>
218 lines
8.6 KiB
Python
218 lines
8.6 KiB
Python
"""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)
|