Files
MeshTerm/meshterm/ui/minimap.py
T
Jean-Pierre MartineauandClaude Fable 5.1 6d7be118b3 The map credits OpenStreetMap on the map, and shrinks the credit once it is used
Only the About page and the README said where the streets came from, which is not
where anyone looking at a map would look for it. OSMF's Attribution Guideline asks
for the credit in a corner of the map itself, visible without having to interact
with it, and lets an interactive map collapse it once the reader pans or zooms so
long as the licence stays findable — an "About option in a menu" is its own worked
example, and we have one.

So the full-screen map is arrived at with "© OpenMapTiles · Data from
OpenStreetMap" — OpenFreeMap's required line, less the half it calls optional — in
its bottom-right corner, and the first key it handles collapses that to "©
OpenStreetMap", which OSMF names as an acceptable form outright. The OSM credit
therefore never actually goes away; the collapse only spends OpenMapTiles' half of
the line. It costs the app no chrome at all: it is stamped over the right end of
the drawing's own bottom row at paint time, so no title atom, no footer character,
no body line, and no reveal key to buy a chip on a full F-key lane. Where the
PicoCalc echoes a find query over that same row the two share it, query left and
credit right, and the query is the half that gives way.

The node page's location preview takes the short form throughout: it is a picture
on somebody else's page rather than a map they went to look at, it has no pan or
zoom for a collapse to trigger on, and the short form is complete standing still.

The About page and the README now name OpenMapTiles too, which neither did, and
the page says out loud that the URL it prints is where the map's corner points —
nothing on a framebuffer console is clickable.

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

185 lines
8.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""A small, static, non-interactive map region embeddable inside any screen.
The full-screen :class:`~meshterm.ui.map_screen.MapScreen` is a whole layer of its own —
it pans, zooms, finds nodes, and persists its view. Some screens only want a *glance*: a
few rows of basemap fixed on one place, with the mesh's markers on it, no controls. The
Node detail screen's "Location" preview is the first such caller — it shows where a node
sits without leaving for the big map (which its *Open full map* action still reaches).
This is the map's tile plumbing distilled to that job: a fixed centre and zoom, its own
little tile cache, background fetches off the event loop (so the host screen never blocks
on the network), and a synchronous :meth:`render` that draws whatever tiles have landed —
refining in place as more arrive, exactly like the big map. With no network and no cached
tiles the basemap is simply absent and the markers plot on a blank grid, so the preview is
always *something*. It owns no keys and resolves nothing; the screen that embeds it just
calls :meth:`render` from its own ``render_body`` and forwards nothing back.
It is a produced work like the big map, so it carries the basemap credit too — but only
ever the remnant form (:data:`~meshterm.ui.attribution.CREDIT_SHORT`), never the whole
line. Two reasons, and the guideline supports both. A preview is five to thirteen rows of
somebody's *node page*, not a map they went to look at, so forty cells of credit across
the bottom of it would be the loudest thing on the row; and the remnant is not a
concession in the first place — "© OpenStreetMap" is one of the two forms OSMF names as
acceptable outright, so nothing here leans on the collapse permission the big map spends.
The preview is also not interactive, which is what makes that distinction matter: there is
no pan or zoom here for a collapse to trigger on, so the mark it draws has to be one that
is complete standing still. OpenMapTiles and the ODbL are named on the About page, which
is the guideline's own worked example of where a credit's licence information may be
found.
"""
from __future__ import annotations
import asyncio
from time import monotonic
from typing import TYPE_CHECKING
from ..core.geo import Viewport, clamp_lat
from ..core.mvt import Layer
from ..services.basemap import TILE_RETRY_SECONDS
from . import attribution
from .map_render import MapMarker, render_map
if TYPE_CHECKING: # pragma: no cover - typing only
from ..services.basemap import BasemapSource
#: How far past the tile source's max zoom a preview may sit — the lower-zoom tiles are
#: magnified to fill it. Matches the big map's overzoom so a close preview still has a
#: (blurred) basemap rather than blank tiles.
_OVERZOOM = 2
def _loop_running() -> bool:
"""Whether there is an event loop to hand background work to.
Asked *before* building a coroutine, not after: ``ensure_future`` without a loop
raises, but by then the coroutine exists and never gets awaited, which Python reports
as a resource warning on a path that is otherwise perfectly correct (a screen rendered
in a test, or a one-shot CLI draw).
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
class MiniMap:
"""A fixed, non-interactive slippy-map region drawn into a host screen's body.
Build one with the resolved tile source (as :func:`~meshterm.ui.map_screen.open_map`
resolves it) plus the centre, zoom, and markers to plot, then call :meth:`render`
from the host's ``render_body`` with the cell width and row height the region should
take. Tiles fetch in the background and the region redraws as they arrive; nothing
here transmits over the radio.
"""
def __init__(
self,
session, # noqa: ANN001 - TuiSession, untyped to avoid an import cycle
source: BasemapSource,
max_tile_zoom: int,
*,
center_lat: float,
center_lon: float,
zoom: int,
markers: list[MapMarker],
) -> None:
"""Create the preview over a fixed view.
Args:
session: The running TUI session — used only to repaint the host screen when
a background tile lands (:meth:`_load`).
source: The vector-tile source, already resolved/warmed by the caller.
max_tile_zoom: The source's max zoom, captured off the event loop at open time
(reading it can touch the network, so the host resolves it once up front).
center_lat: Latitude the preview centres on.
center_lon: Longitude the preview centres on.
zoom: Display zoom; clamped to a sane range against the source's max.
markers: The located mesh nodes to overlay (may be empty).
"""
self._session = session
self._source = source
self._max_tile_zoom = max_tile_zoom
self._center_lat = clamp_lat(center_lat)
self._center_lon = center_lon
self._zoom = max(2, min(int(zoom), max_tile_zoom + _OVERZOOM))
self._markers = markers
# Decoded tiles keyed by (z, x, y); a stored ``None`` is the source's own word that
# there is no tile there. Silence is not that answer and is not stored here.
self._tiles: dict[tuple[int, int, int], list[Layer] | None] = {}
self._pending: set[tuple[int, int, int]] = set()
# Tiles we got no answer about, and when each may be asked for again.
self._unanswered: dict[tuple[int, int, int], float] = {}
@property
def has_basemap(self) -> bool:
"""Whether the source has resolved a basemap to draw from — not yet is not never.
The preview keeps asking either way (see :meth:`_ensure_tiles`); this is for a
host that wants to caption the wait.
"""
return self._source.available
@property
def pending(self) -> int:
"""How many visible tiles are still in flight — for a host's loading note."""
return len(self._pending)
def render(self, width: int, rows: int) -> list[str]:
"""Render the preview at ``width`` cells by ``rows`` rows, scheduling tile loads.
Builds a :class:`~meshterm.core.geo.Viewport` sized to this region (each cell is
two braille dots wide, each row four tall) fixed on the preview's centre and zoom,
kicks off background fetches for any not-yet-loaded tiles, and draws the frame from
whatever has landed. Called every paint; the viewport is cheap to rebuild, so a
resize just re-sizes it.
Args:
width: Region width in character cells.
rows: Region height in character cells.
Returns:
One ANSI string per row (exactly ``rows`` of them, as the canvas fills its box).
"""
viewport = Viewport(
self._center_lat, self._center_lon, self._zoom, max(2, width) * 2, max(1, rows) * 4
)
self._ensure_tiles(viewport)
tiles = {t: self._tiles.get(t) for t in viewport.tiles(self._max_tile_zoom)}
lines = render_map(viewport, tiles, self._markers)
return attribution.stamp(lines, width, full=False)
def _ensure_tiles(self, viewport: Viewport) -> None:
"""Schedule background fetches for any visible tile we don't have and aren't owed.
Not gated on :attr:`~meshterm.services.basemap.BasemapSource.available`, and a
tile the source gave no answer about is asked for again once its cooldown runs
out — the big map's rules, and for its reasons (see
:meth:`meshterm.ui.map_screen.MapScreen._ensure_tiles`).
"""
if not _loop_running(): # nothing to fetch onto — draw whatever is already here
return
if self._unanswered: # the silences that have served their time
now = monotonic()
self._unanswered = {t: at for t, at in self._unanswered.items() if at > now}
for t in viewport.tiles(self._max_tile_zoom):
if t in self._tiles or t in self._pending or t in self._unanswered:
continue
self._pending.add(t)
asyncio.ensure_future(self._load(t))
async def _load(self, t: tuple[int, int, int]) -> None:
"""Fetch+decode one tile off the event loop, then repaint the host screen."""
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._pending.discard(t)
if layers is not None or self._source.answered_empty(*t):
self._tiles[t] = layers # an answer, settled for the session
else:
self._unanswered[t] = monotonic() + TILE_RETRY_SECONDS # silence — ask again
self._session.invalidate()