Files
MeshTerm/meshterm/tools/map.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

118 lines
4.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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:`~meshterm.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``).
Menu-only: it opens a full-screen, pannable/zoomable map (see
:mod:`meshterm.ui.map_screen`) and registers no CLI subcommand, because a map is a picture
and the scripted CLI deals in facts (see :meth:`MapTool.register_cli`). Repeaters are
prioritised over ordinary nodes: a distinct marker, drawn on top. 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
import typer
from ..context import AppContext
from ..services.markers import gather_markers
from .base import Tool, ToolResult, register
def _fraction(ctx: AppContext, params: dict[str, Any]) -> float:
"""How much of the mesh a map opens framed on: the flag if one was passed, else the preference.
``--fraction`` is resolved here rather than as the Typer option's default because the
options are declared at CLI *registration* time — before a context, and so before the
preferences file has been read.
Args:
ctx: Shared application context.
params: This invocation's parameters.
Returns:
The fraction to frame.
"""
fraction = params.get("fraction")
return ctx.preferences.map_view_fraction if fraction is None else float(fraction)
if TYPE_CHECKING:
from ..core.models import MapMarker
@register
class MapTool(Tool):
"""Show the mesh's location-sharing nodes on a braille OpenStreetMap map."""
name = "map"
title = "Map"
icon = "🌍"
help = "Mesh nodes on a pannable street map"
category = "Explore"
order = 10
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Gather the located nodes and open the interactive map.
Menu-only (see :meth:`register_cli`), so there is one path through here.
Args:
ctx: Shared application context.
params: Optional ``fraction`` — how much of the mesh to open framed on.
Returns:
A :class:`ToolResult` summarizing how many nodes were plotted.
"""
from ..ui.map_screen import open_map
markers = await self._gather(ctx)
if not markers:
ctx.ui.note(
"[muted]no contacts or heard nodes have shared a location yet — a node "
"appears here once it advertises coordinates[/muted]"
)
return ToolResult(summary={"located": 0})
await open_map(ctx, markers, fraction=_fraction(ctx, 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,
},
)
# -- marker gathering -------------------------------------------------------
async def _gather(self, ctx: AppContext) -> list[MapMarker]:
"""Collect every located node to plot (see :func:`gather_markers`)."""
return await gather_markers(ctx)
def register_cli(self, app: typer.Typer) -> None:
"""Register no CLI command — a map is a picture, and pictures are menu-only.
Every other feature has a scripted face because its answer is a set of facts a
script can act on. A map's answer is a *drawing*: braille cells whose meaning is
their position on a grid and whose nodes are told apart by colour, which is
exactly what the scripted CLI does not have (see :mod:`meshterm.ui.script`). A
one-shot render there would be an unparseable block of glyphs, and stripping its
colour to match the rest of the CLI would make it unreadable as well.
The located nodes themselves are still scriptable — ``meshterm contacts`` lists
every one of them, coordinates included.
Args:
app: The Typer application (untouched).
"""