Files
MeshTerm/meshterm/ui/pathgraph.py
T
Jean-Pierre MartineauandClaude Opus 4.8 4f78ba5a54 Path graph: keep node labels whole until they overflow the canvas
The fan-lane route graph clipped every label to a fixed 12-cell budget, so
node names past twelve characters ellipsized even with room to spare. Cap the
label at the physical canvas width instead: a name is kept whole unless it is
wider than the whole canvas (nowhere for the cells to go), and the placement
pass — which already slides an endpoint's label inward or drops a crowded
relay's — decides where it lands. Message-paths endpoints now read in full.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:50:56 -04:00

249 lines
11 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.
"""THE route-graph widget: hop sequences drawn as fanned braille lanes between us-es.
Extracted from the Message paths dialog so every screen that draws walked (or planned)
routes draws them the same way. A *layer* is one path — its relay hops, an edge colour,
and a draw priority — and the widget lays every distinct path out between a left and a
right endpoint marker:
* **columns** are the distinct node depths, spread evenly across the width, so forks
read clearly however lopsided the paths' lengths are;
* **lanes** fan out from the vertical centre in layer order (first layer on the centre
line, the next below, the next above, …); a node shared between paths averages its
lanes, so a common relay pulls the paths together where they actually met;
* **shared cells** go to the highest-priority layer (drawn last), so e.g. the Message
paths dialog's selected path reads white over the unused grey;
* **labels** sit straight above or below their marker — the side away from the centre
line first, a spot clear of the drawn lines preferred — and endpoints try the same
vertical placements before falling back beside the marker. A caller that wants a
node unlabelled returns ``None`` for it.
Everything renders onto a :class:`~meshterm.ui.mapcanvas.MapCanvas`; the caller supplies
the per-node glyph/label/colour callbacks, so the widget stays free of contact-list and
theme concerns.
"""
from __future__ import annotations
from dataclasses import dataclass
from math import ceil
from typing import Callable, Optional, Sequence
from .mapcanvas import RGB, MapCanvas, parse_hex
#: Sentinel node ids for the graph's endpoints (NUL never collides with hex hops).
#: Callers key their glyph/label callbacks on these for the two ends of every path.
SRC_NODE = "\x00src"
DST_NODE = "\x00dst"
#: Vertical dot separation between path lanes.
_LANE_STEP_DOTS = 12
#: Dots reserved beyond the outermost lane at each end of the fan — a label row for
#: that lane's marker, plus a little air. The box is sized and centred on the fan's
#: real extent (which is rarely symmetric), so it spends its rows on the paths rather
#: than on a mirrored half that stays empty.
_GRAPH_END_DOTS = 8
#: The dot row within a character cell a horizontal edge line is aimed at — the
#: upper-middle of the cell's 2×4 pixel grid (rows 0..3 top-down), where a one-dot
#: line reads as running through the glyph rather than hugging its bottom edge. Every
#: node's y is snapped onto this row (see ``pos``) so same-lane runs stay level there.
_CELL_MID_DOT = 1
#: Dot-space margin the endpoint markers keep from the canvas edges.
_GRAPH_PAD_DOTS = 6
#: A node's graph marker: the glyph and its ``#rrggbb`` colour (the shared node-glyph
#: tuples from :mod:`~meshterm.ui.map_render` / :mod:`~meshterm.ui.widgets` fit as-is).
GlyphOf = Callable[[str], tuple[str, str]]
#: A node's graph label, or ``None``/``""`` to leave the marker bare.
LabelOf = Callable[[str], Optional[str]]
#: The colour a node's label is drawn in (usually the node's name hue).
LabelRgbOf = Callable[[str], RGB]
@dataclass(frozen=True)
class PathLayer:
"""One path drawn on the route graph.
Attributes:
hops: The relay node ids between the endpoints, in walk order (empty = the
path runs endpoint to endpoint on the centre line).
color: The edge colour the path draws in.
priority: Draw priority — where paths share a cell, the highest priority
keeps it (its edges are also drawn last).
"""
hops: tuple[str, ...]
color: RGB
priority: int
def _mid_row(y_dot: int) -> int:
"""Snap a dot row onto the upper-middle dot of its character cell.
A braille cell is four dot rows tall; a horizontal line drawn on the top or
bottom row hugs the glyph's edge and reads as sitting too high or too low.
Snapping every node's y to :data:`_CELL_MID_DOT` keeps markers — and the level
runs between same-lane nodes — centred in the cell's pixel space.
"""
return round((y_dot - _CELL_MID_DOT) / 4) * 4 + _CELL_MID_DOT
def render_path_graph(
layers: Sequence[PathLayer],
width: int,
*,
glyph_of: GlyphOf,
label_of: LabelOf,
label_rgb_of: LabelRgbOf,
min_rows: int = 5,
max_rows: int = 15,
) -> list[str]:
"""Draw the layered route graph and return its ANSI lines.
Args:
layers: The paths to draw, in *lane* order (the first rides the centre line,
the next fans below, the next above, …). Layers with identical hop
sequences collapse to one drawn path owned by the highest priority among
them — re-walking a known route highlights it rather than doubling it.
width: Canvas width in character cells.
glyph_of: Marker glyph + colour per node id (endpoints keyed by
:data:`SRC_NODE` / :data:`DST_NODE`).
label_of: Label text per node id (``None``/``""`` = bare marker). A label is
kept whole unless it is wider than the canvas, when it is ellipsized to fit.
label_rgb_of: Label colour per node id.
min_rows: The fewest canvas rows to draw, however flat the fan.
max_rows: The most canvas rows to spend; a taller fan compresses its lane
spacing to fit.
Returns:
One ANSI string per canvas row (empty when there are no layers to draw).
"""
if not layers:
return []
# Collapse identical paths onto their highest-priority layer, preserving first
# appearance for lane order; then fan lanes over the relayed paths (a direct
# path rides the centre line anyway): 0, -1, 1, -2, 2…
drawn: list[PathLayer] = []
by_hops: dict[tuple[str, ...], int] = {}
for layer in layers:
at = by_hops.get(layer.hops)
if at is None:
by_hops[layer.hops] = len(drawn)
drawn.append(layer)
elif layer.priority > drawn[at].priority:
drawn[at] = PathLayer(layer.hops, layer.color, layer.priority)
relayed = [layer.hops for layer in drawn if layer.hops]
lane_of = {hops: (-1) ** k * ((k + 1) // 2) for k, hops in enumerate(relayed)}
# Positions: a node's depth is its mean relative slot along the paths through
# it; the distinct depths then map to evenly spaced columns (endpoints pinned
# to the margins). y is the mean lane, centre-pinned for the endpoints — a
# shared relay averages toward the middle.
rel: dict[str, list[float]] = {}
lanes: dict[str, list[int]] = {}
seqs = [(SRC_NODE, *layer.hops, DST_NODE) for layer in drawn]
for layer, seq in zip(drawn, seqs):
hops = len(seq) - 1
for i, node in enumerate(seq):
rel.setdefault(node, []).append(i / hops)
lanes.setdefault(node, []).append(lane_of.get(layer.hops, 0))
depth = {node: sum(r) / len(r) for node, r in rel.items()}
columns = sorted(set(depth.values()))
slot = {d: i / max(1, len(columns) - 1) for i, d in enumerate(columns)}
lane_y = {
node: sum(l) / len(l)
for node, l in lanes.items()
if node not in (SRC_NODE, DST_NODE)
}
# Height follows where the relays actually land after lane-averaging — and how
# far the fan reaches *each* way, which is rarely symmetric (lanes fan 0, −1, +1,
# −2, …). Sizing to the real up/down reach and centring the endpoints' lane-0
# line on it keeps the box off the empty half a mirrored block would leave.
step = _LANE_STEP_DOTS
ups = -min([0.0, *lane_y.values()]) # lanes rising above the lane-0 line
downs = max([0.0, *lane_y.values()]) # …and dropping below it
def sized(step: int) -> tuple[int, int]:
return (round(ups * step) + _GRAPH_END_DOTS,
round(downs * step) + _GRAPH_END_DOTS)
top, bot = sized(step)
rows = max(min_rows, ceil((top + bot) / 4))
if rows > max_rows:
rows = max_rows
reach = ups + downs
if reach:
step = max(4, int((rows * 4 - 2 * _GRAPH_END_DOTS) / reach))
top, bot = sized(step)
canvas = MapCanvas(width, rows)
dot_w = width * 2
cy = _mid_row(top)
span = dot_w - 2 * _GRAPH_PAD_DOTS
def pos(node: str) -> tuple[int, int]:
x = _GRAPH_PAD_DOTS + round(slot[depth[node]] * span)
if node in (SRC_NODE, DST_NODE):
return x, cy
return x, _mid_row(cy + round(lane_y[node] * step))
# Edges: ascending priority, so where paths share cells the top layer is drawn
# last and keeps them.
for layer, seq in sorted(zip(drawn, seqs), key=lambda ls: ls[0].priority):
for i in range(len(seq) - 1):
canvas.draw_line([pos(seq[i]), pos(seq[i + 1])], layer.color, layer.priority)
# Markers for every node; labels for the nodes the caller names. Endpoints then
# the topmost layer's hops go first so they win collisions. Every label sits
# straight above or below its marker — the side away from the centre line first,
# a spot clear of the drawn lines preferred — with a beside-the-marker placement
# as the endpoints' last resort (their edge columns can starve a centred run).
for node in rel:
glyph, color_hex = glyph_of(node)
canvas.marker(*pos(node), glyph, parse_hex(color_hex))
labelled = [DST_NODE, SRC_NODE]
for layer in sorted(drawn, key=lambda l: -l.priority):
for hop in layer.hops:
if hop not in labelled:
labelled.append(hop)
for node in labelled:
label = label_of(node)
if not label:
continue
# A name only shortens when it is wider than the whole canvas — there is
# physically nowhere for the extra cells to go. Anything that fits the width is
# kept whole and handed to the placement pass below, which slides an endpoint's
# label inward (or drops a crowded relay's) rather than pre-clipping a name that
# would have fit.
if len(label) > width:
label = label[: max(1, width - 1)] + "…"
rgb = label_rgb_of(node)
x, y = pos(node)
# An endpoint hugs a canvas edge, where a run centred on its marker falls
# off; its label anchor slides inward just far enough to fit, so the name
# still lands above/below the star instead of being dropped.
anchor_x = x
if node in (SRC_NODE, DST_NODE):
half = len(label) // 2
cx = min(max(x >> 1, half), max(half, width - (len(label) - half)))
anchor_x = cx * 2
rows_out = (y - 4, y + 4) if y <= cy else (y + 4, y - 4)
if any(
canvas.place_label(anchor_x, sy, label, rgb, bold=True, avoid_dots=True)
for sy in rows_out
):
continue
if any(
canvas.place_label(anchor_x, sy, label, rgb, bold=True) for sy in rows_out
):
continue
if node in (SRC_NODE, DST_NODE):
if not canvas.marker_label(x, y, label, rgb, avoid_dots=True):
canvas.marker_label(x, y, label, rgb)
return canvas.to_ansi_lines()