Files
MeshTerm/meshtools/ui/mapcanvas.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

253 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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