Files
MeshTerm/meshterm/ui/map_render.py
T
Jean-Pierre MartineauandClaude Opus 4.8 6cfe435d55 Redesign the nodes page with heat-map recency and live sorting
Sort contacts alphabetically with our own node pinned first, and add
Heard (relative age) and Pkts (overheard-packet) columns alongside a
per-type glyph and the full path-hash-highlighted key. Drop the boxed
table for a clean header rule and a glyph legend.

Colour each name by a continuous log-scale heat map of how recently it
was heard (white -> yellow -> orange -> red -> grey), and give the
node-type icons a shared, map-safe palette across the whole app: a
yellow star for us, a loud pink node and a calmer violet repeater
(both in hues no basemap uses, so nodes pop more than repeaters),
a white room and an orange sensor.

In the menu the list becomes an interactive screen: left/right pick the
sort column (cyan header + direction triangle), up/down set ascending/
descending, with column widths reserved so toggling never shifts the
row. The scripted CLI keeps a one-shot render via --sort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:38:28 -04:00

368 lines
13 KiB
Python

"""Compose a rendered map frame: basemap geometry, labels, and mesh nodes.
Given a :class:`~meshterm.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:`meshterm.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
import math
from collections import Counter
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)
# Marker palette, shared across the whole app. The basemap is blue (water), green (parks) and
# warm amber (roads), so markers use the hues a map never contains — pink/violet — to stand
# out against it. Ordinary nodes are the loudest (bright pink); repeaters recede a step (a
# calmer violet); our own node stays the fixed yellow star.
_SELF = ("★", "#facc15")
_REPEATER = ("▲", "#a78bfa")
_NODE = ("●", "#f472b6")
# -- 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 _marker_style(marker: MapMarker) -> tuple[str, str]:
"""The glyph and colour for a marker: self, repeater, or leaf node."""
if marker.is_self:
return _SELF
if marker.is_repeater:
return _REPEATER
return _NODE
#: How many piled nodes it takes for a marker's glyph to reach full brightness.
_PILE_FULL = 8
def _pile_color(color: RGB, count: int, *, cap: int = _PILE_FULL) -> RGB:
"""Brighten a marker's colour by how many nodes share its cell.
One cell can only show a single glyph, so where many nodes fall on the same spot the
map would otherwise hide the crowd behind one marker. Instead we keep the top node's
glyph and wash its colour toward white as the pile grows, so a bright marker reads as
a busy cluster. The ramp is logarithmic (2 nodes → a clear lift, saturating around
``cap``) so it stays informative without a lone extra node looking crowded.
"""
if count <= 1:
return color
t = min(1.0, math.log2(count) / math.log2(cap))
r, g, b = color
return (
round(r + (255 - r) * t),
round(g + (255 - g) * t),
round(b + (255 - b) * t),
)
def _draw_nodes(canvas: MapCanvas, viewport: Viewport, markers: list[MapMarker]) -> None:
"""Overlay mesh nodes: every glyph, then labels by importance until they collide.
Glyphs are drawn lowest-priority first so self/repeaters land on top of leaf nodes.
Where several nodes share a cell the surviving glyph is brightened by the pile size
(:func:`_pile_color`) so crowded spots glow rather than silently hiding the crowd.
Labels are then placed highest-priority first — self, then repeaters, then leaf
nodes — each only if it fits without overlapping. So on a crowded map the important
labels win the available space and the rest show as a bare marker (no overlap).
"""
placed: list[tuple[MapMarker, int, int]] = []
for marker in markers:
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
placed.append((marker, ix, iy))
# A cell is (dot_x >> 1, dot_y >> 2); count how many nodes land on each.
pile = Counter((ix >> 1, iy >> 2) for _, ix, iy in placed)
for marker, ix, iy in sorted(placed, key=lambda p: p[0]._rank()):
glyph, color = _marker_style(marker)
count = pile[(ix >> 1, iy >> 2)]
canvas.marker(ix, iy, glyph, _pile_color(parse_hex(color), count))
for marker, ix, iy in sorted(placed, key=lambda p: -p[0]._rank()):
_, color = _marker_style(marker)
canvas.marker_label(ix, iy, marker.label, parse_hex(color))