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>
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""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()
|