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

314 lines
11 KiB
Python

"""Compose a rendered map frame: basemap geometry, labels, and mesh nodes.
Given a :class:`~meshtools.core.geo.Viewport` and the decoded vector tiles covering it, this
projects every street, river, water body and place label onto a :class:`MapCanvas`, then
overlays the mesh nodes with their names on top. It is pure and synchronous — tile *fetching*
happens elsewhere (:mod:`meshtools.services.basemap`) — so both the interactive screen and
the one-shot CLI render call the same code.
Colours target a dark terminal (the app theme): warm roads, grey minor streets, blue water
and rivers, faint green parks. Because a cell shows one colour, draw priorities keep the
important feature visible where things overlap (rivers over water, major roads over minor).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from ..core.geo import Viewport
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from .mapcanvas import RGB, MapCanvas, parse_hex
# -- node markers -------------------------------------------------------------
@dataclass(slots=True)
class MapMarker:
"""One mesh node to overlay on the map.
Attributes:
label: Node name shown beside the marker.
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
is_repeater: Whether the node is a repeater (prioritised marker).
is_self: Whether this is our own node (highlighted).
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
"""
label: str
lat: float
lon: float
is_repeater: bool = False
is_self: bool = False
detail: str = ""
def _rank(self) -> int:
"""Draw order: self on top of repeaters on top of leaf nodes."""
return 2 if self.is_self else (1 if self.is_repeater else 0)
_SELF = ("★", "#ffffff")
_REPEATER = ("▲", "#ff5d73")
_NODE = ("●", "#22d3ee")
# -- basemap styling ----------------------------------------------------------
# Road class -> (colour, priority). Higher priority wins a shared cell.
_ROAD_STYLE: dict[str, tuple[str, int]] = {
"motorway": ("#f2a13d", 27),
"trunk": ("#f2a13d", 26),
"primary": ("#e8b24a", 25),
"secondary": ("#d7c257", 24),
"tertiary": ("#a7adb8", 23),
"minor": ("#828894", 21),
"unclassified": ("#828894", 21),
"residential": ("#828894", 21),
"living_street": ("#767c88", 20),
"service": ("#5f646e", 19),
"pedestrian": ("#5a5f69", 15),
"path": ("#4b5560", 12),
"track": ("#4b5560", 12),
"footway": ("#454e58", 11),
"cycleway": ("#455864", 12),
}
_ROAD_DEFAULT = ("#5f646e", 18)
_RAIL = ("#8a8f98", 22)
# Waterway class -> (colour, priority).
_WATERWAY_STYLE: dict[str, tuple[str, int]] = {
"river": ("#49b0ec", 30),
"canal": ("#49b0ec", 30),
"stream": ("#3f8fbf", 29),
"ditch": ("#3a7ba6", 28),
"drain": ("#3a7ba6", 28),
}
_WATER_FILL = ("#153b56", 6)
_GREEN_FILL = ("#173a29", 4)
_GREEN_CLASSES = {"wood", "forest", "grass", "park", "meadow", "scrub", "wetland", "cemetery"}
# Label styling per place class: (colour, bold, rank, min_display_zoom). Lower rank places
# claim screen space first; min zoom hides the fine detail until you zoom in.
_PLACE_STYLE: dict[str, tuple[str, bool, int, int]] = {
"city": ("#f8fafc", True, 0, 0),
"town": ("#e6ebf2", True, 1, 9),
"village": ("#cdd6e2", False, 2, 12),
"suburb": ("#9fb0c4", False, 3, 12),
"quarter": ("#94a5ba", False, 4, 13),
"neighbourhood": ("#8496ab", False, 5, 13),
"hamlet": ("#8496ab", False, 6, 14),
}
_WATER_LABEL = ("#7dd3fc", False, 2)
_STREET_LABEL = ("#9aa0aa", False, 7)
@dataclass(slots=True)
class _Label:
"""A pending label placement candidate."""
rank: int
x: float
y: float
text: str
color: RGB
bold: bool
min_zoom: int = 0
@dataclass(slots=True)
class _Frame:
"""Working state while composing one frame."""
canvas: MapCanvas
viewport: Viewport
labels: list[_Label] = field(default_factory=list)
def render_map(
viewport: Viewport,
tiles: dict[tuple[int, int, int], Optional[list[Layer]]],
markers: list[MapMarker],
*,
max_labels: int = 80,
) -> list[str]:
"""Render a full map frame to truecolour ANSI lines.
Args:
viewport: The view to render (its dot size sets the canvas size).
tiles: Decoded layers keyed by ``(z, x, y)``; ``None`` values are pending/absent.
markers: Mesh nodes to overlay.
max_labels: Cap on basemap labels placed, to keep the map readable.
Returns:
One ANSI string per row, ready for the TUI frame or the console.
"""
canvas = MapCanvas(viewport.dot_w // 2, viewport.dot_h // 4)
frame = _Frame(canvas=canvas, viewport=viewport)
for (z, x, y), layers in tiles.items():
if layers:
_draw_tile(frame, layers, z, x, y)
# Nodes reserve their cells first so basemap labels route around them.
_draw_nodes(canvas, viewport, markers)
# Then place basemap labels by importance, honouring the zoom gate and collisions.
placed = 0
for label in sorted(frame.labels, key=lambda l: l.rank):
if placed >= max_labels:
break
if viewport.zoom < label.min_zoom:
continue
if canvas.place_label(label.x, label.y, label.text, label.color, bold=label.bold):
placed += 1
return canvas.to_ansi_lines()
def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> None:
"""Draw one decoded tile's geometry and collect its label candidates."""
vp = frame.canvas
by_name = {layer.name: layer for layer in layers}
def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]:
return [
frame.viewport.feature_to_dot(x, y, z, extent, lx, ly) for lx, ly in ring
]
# Fills first (water, green space) so lines and labels sit on top.
for name in ("water", "landcover", "landuse", "park"):
layer = by_name.get(name)
if layer is None:
continue
for feat in layer.features:
if feat.geom_type != GEOM_POLYGON:
continue
if name == "water":
color, prio = _WATER_FILL
elif str(feat.get("class") or feat.get("subclass")) in _GREEN_CLASSES:
color, prio = _GREEN_FILL
else:
continue
vp.fill_polygon(
[project(r, layer.extent) for r in feat.rings], parse_hex(color), prio
)
# Waterways (rivers/streams) as lines.
waterway = by_name.get("waterway")
if waterway is not None:
for feat in waterway.features:
style = _WATERWAY_STYLE.get(str(feat.get("class")))
if style is None or feat.geom_type != GEOM_LINE:
continue
color, prio = style
for ring in feat.rings:
if len(ring) >= 2:
vp.draw_line(project(ring, waterway.extent), parse_hex(color), prio)
if feat.name:
_add_line_label(frame, feat.rings, waterway.extent, z, x, y, feat.name, _WATER_LABEL)
# Roads / rail.
transportation = by_name.get("transportation")
if transportation is not None:
for feat in transportation.features:
if feat.geom_type != GEOM_LINE:
continue
cls = str(feat.get("class") or "")
if cls in ("rail", "transit"):
color, prio = _RAIL
else:
color, prio = _ROAD_STYLE.get(cls, _ROAD_DEFAULT)
rgb = parse_hex(color)
for ring in feat.rings:
if len(ring) >= 2:
vp.draw_line(project(ring, transportation.extent), rgb, prio)
# Boundaries (admin) as a faint hint.
boundary = by_name.get("boundary")
if boundary is not None:
for feat in boundary.features:
if feat.geom_type != GEOM_LINE:
continue
try:
if int(feat.get("admin_level", 99)) > 6:
continue
except (TypeError, ValueError):
continue
for ring in feat.rings:
if len(ring) >= 2:
vp.draw_line(project(ring, boundary.extent), parse_hex("#6d5f88"), 14)
# Street-name labels (only kick in at high zoom via the label's min_zoom gate).
tname = by_name.get("transportation_name")
if tname is not None:
color, bold, rank = _STREET_LABEL
for feat in tname.features:
if feat.name and feat.rings:
_add_line_label(
frame, feat.rings, tname.extent, z, x, y, feat.name,
(color, bold, rank), min_zoom=15,
)
# Place labels.
place = by_name.get("place")
if place is not None:
for feat in place.features:
if not feat.name or not feat.rings or not feat.rings[0]:
continue
style = _PLACE_STYLE.get(str(feat.get("class")))
if style is None:
continue
color, bold, rank, min_zoom = style
lx, ly = feat.rings[0][0]
dx, dy = frame.viewport.feature_to_dot(x, y, z, place.extent, lx, ly)
frame.labels.append(
_Label(rank, dx, dy, feat.name, parse_hex(color), bold, min_zoom)
)
# Water-body names.
water_name = by_name.get("water_name")
if water_name is not None:
color, bold, rank = _WATER_LABEL
for feat in water_name.features:
if feat.name and feat.rings and feat.rings[0]:
lx, ly = feat.rings[0][0]
dx, dy = frame.viewport.feature_to_dot(x, y, z, water_name.extent, lx, ly)
frame.labels.append(_Label(rank, dx, dy, feat.name, parse_hex(color), bold, 8))
def _add_line_label(
frame: _Frame,
rings: list[list[tuple[int, int]]],
extent: int,
z: int,
x: int,
y: int,
text: str,
style: tuple[str, bool, int],
*,
min_zoom: int = 0,
) -> None:
"""Queue a label at the midpoint of a line feature's longest part."""
longest = max(rings, key=len)
lx, ly = longest[len(longest) // 2]
dx, dy = frame.viewport.feature_to_dot(x, y, z, extent, lx, ly)
color, bold, rank = style
frame.labels.append(_Label(rank, dx, dy, text, parse_hex(color), bold, min_zoom))
def _draw_nodes(canvas: MapCanvas, viewport: Viewport, markers: list[MapMarker]) -> None:
"""Overlay mesh nodes (lowest priority first, so self/repeaters land on top)."""
for marker in sorted(markers, key=lambda m: m._rank()):
x, y = viewport.lonlat_to_dot(marker.lat, marker.lon)
ix, iy = int(round(x)), int(round(y))
if not (0 <= ix < viewport.dot_w and 0 <= iy < viewport.dot_h):
continue
if marker.is_self:
glyph, color = _SELF
elif marker.is_repeater:
glyph, color = _REPEATER
else:
glyph, color = _NODE
canvas.marker(ix, iy, glyph, parse_hex(color), label=marker.label)