Files
MeshTerm/meshterm/ui/pathgraph.py
T
Jean-Pierre MartineauandClaude Opus 4.8 c008fe56d2 Path graph: run the lane-change curve corner-free so it reads thin
The lane change kept a flat platform each side of the curve, where the nodes
sat. But a platform (slope 0) meets the climbing curve at a corner, and a
corner draws a heavy braille knee — a cell filled 3-4 dots — that reads thick,
the more so the shallower the curve's end tangent. Flattening the S last time
(BEND_K 0.5 -> 0.2) thinned the middle but sharpened that corner, trading a
thick centre for a thick knee: the platform model can't make both thin.

Drop the platforms: span the curve across the whole column gap (CURVE_SPAN
0.9 -> 1.0) so it runs continuously node to node with no corner to pile a
knee, and keep the end tangents level (BEND_K 0.2 -> 0.4) so each marker still
seats on a flat point without a platform run. The knees become gentle
rise-and-settle arcs and the stroke stays even and thin end to end. The honest
cost: in a very tight column a full-lane shift is steep right to the node, so
its marker sits on a slight slope rather than dead level — thinness bought with
a hair of level seating, the trade this screen wants.

Verified by rasterising to the dot grid: the curves are already 1-pixel-wide
(a morphological thinning pass removes zero dots from them), so the remaining
weight was never removable fat — it was the corner, now gone. What density is
left sits at endpoint convergence (many routes merging), which is not a lane
change and is left alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:55:03 -04:00

976 lines
50 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 a left-to-right flow of paths.
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 shared left
and right endpoint marker.
Every path shares its two endpoints (an origin on the left, us on the right) and is walked
left to right, so the picture is a *flow*: routes that **diverge** out of the origin, run
their own course, and **converge** back into us, sharing a relay wherever their walks agree.
Earlier drawings tried a vertical *bus* the lanes tapped at right angles (a metro map you
wander around, the bare 90° turns hiding that A flows to B), then oblique branches straight
off each marker (which left every off-lane relay a pointed *peak* or *valley*). The widget
now draws the fan as a **multilane highway**: a node sits level in its lane, and a route changes
lane only *between* nodes, easing across on a single gentle shift the way a car drifts one lane
over and then settles. Concretely:
* **lanes** — the highest-*priority* path (the spine, e.g. the best-evidence route) holds the
flow's centre, its relays in a straight run, and the alternatives fan above and below it a
fixed pitch of text rows apart. Priority fixes the geometry; a separate *emphasis* rank picks
which route is drawn highlighted (on top, winning any shared cell) without moving a marker, so
a caller can light a different route without the picture reflowing. Rather than give every
route a full-width lane of its own —
which stacks the band as tall as the route count even where the routes barely overlap — the
lanes are *packed by column*: the spine keeps its own relays, and each other node slides to the
innermost free row above or below the spine *in its own column*, so a column with one off-spine
node claims a single flanking row however many routes cross the graph, and only a column where
routes genuinely stack pays the deeper rows. Which flank an alternative takes is then *balanced*
so column-sharing routes split above and below rather than piling one flank two deep while the
other sits empty (the band is only as tall as its deepest flank plus the other's): a five-route
fan through at most two nodes per column draws three lanes deep — spine plus one flank each side
— not five. The two endpoints sit at the vertical centre of the packed band, so the spine runs
through them — dead straight when the flanks come out even and the lanes are odd, leaning gently
to the centre otherwise (and where the lanes are even, one extra padding row is opened between
the two central lanes so the endpoints land on an exact centred row between them);
* **columns** (x) place each node by *balanced* rank — its distance from the origin over its
distance-plus-remaining-distance to us — so a path's relays spread evenly between the two
ends however long the other paths are, and a shared relay lands in one place;
* **level seating** — every node is entered and left on a *level tangent*, so a relay that sits
off its neighbours' lane reads as a gentle rise-and-settle, never a pointed peak or valley.
Between two nodes on different lanes the shift leaves the first level, drifts across, and
arrives level into the second — the only bends are the soft level→curve→level eases, never a
bare right angle in open canvas. The curve spans the whole gap rather than a centred stretch
flanked by flat platforms: a platform-to-curve corner draws a heavy braille *knee*, so the
stroke runs continuously node to node and stays an even, thin arc (see :data:`_CURVE_SPAN`);
* **shared relays** draw as a single marker (a route re-using a hop is not a new node): the
marker sits on its highest-priority path's lane, and a lower path that also rides it leans
off its lane to meet the marker and back — which reads as the alternative *branching
through the shared node*, exactly the story the evidence tells;
* **diverge / converge** — the fan at each end is flow, not a switchboard: routes leave the
origin overlapping on the centre line and peel off one by one to their lanes (the
divergence), and mirror that back into us on the right (the convergence). Every edge draws
exactly once, however many routes share it; the one edge drawn as a bare vertical is a pair
of nodes walked in *both* directions — a genuine two-way hop, the sole place a straight
up-and-down line tells the truth;
* **labels** sit straight above or below their marker — pushed to the side away from the
graph's middle, a spot clear of the drawn lines preferred — and endpoints slide their
label inward from the canvas edge so a long name still lands by its 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 itertools import permutations, product
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"
#: Text rows between adjacent lane markers — the pitch one lane maps to. At the default there
#: are two blank rows padding each gap (room for a lane's label and its neighbour's), and an
#: *even* lane count opens one extra row between the two central lanes so the endpoints land on
#: an exact centred row (see the vertical-sizing block). A graph with more lanes than fit the
#: row budget scales the pitch down to fit, and a graph with few lanes never stretches *past* it
#: (spreading the lanes apart would only reintroduce the empty space the layout exists to
#: avoid), so a sparse graph draws compact rather than splayed.
_LANE_PITCH_ROWS = 3
#: Dots reserved beyond the outermost lane at each end of the graph — a label row for that
#: lane's markers, plus a little air.
_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 lane's y is snapped onto this
#: row (see :func:`_mid_row`) so a level run stays level and same-column markers align.
_CELL_MID_DOT = 2
#: Dot-space margin the endpoint markers keep from the canvas edges.
_GRAPH_PAD_DOTS = 6
#: The share of a lane change's horizontal column gap spent on the eased curve itself — the rest
#: would split into two flat platforms flanking it. Set to the whole gap: the curve runs
#: continuously from one node to the next with *no* separate flat segment. A shorter curve leaves
#: flat platforms but reintroduces a **corner** where a platform (slope 0) meets the climbing
#: curve, and that corner piles a heavy braille *knee* (a cell filled 3–4 dots) that reads thick —
#: the more so the shallower the curve's end tangent. Spanning the whole gap removes the corner,
#: so the stroke stays an even, thin arc end to end; the nodes still seat level because the curve's
#: end tangents are kept horizontal (see :data:`_BEND_K`), giving each marker a flat point to sit on
#: without a platform run. (In a very tight column a full-lane shift is unavoidably steep right up to
#: the node, so its marker sits on a gentle slope rather than dead level — the honest cost of keeping
#: the stroke thin there.)
_CURVE_SPAN = 1.0
#: The shortest horizontal run a lane change is given even for a one-lane hop, so a tight
#: column gap still bends across a few dots rather than snapping over in one abrupt step.
_MIN_SHIFT_DOTS = 4
#: How far (as a fraction of the shift's horizontal span) the bezier control points sit in from
#: each end — both placed level with their own end, so the curve leaves and enters each node
#: horizontally. This flat end tangent is what seats a node level now that the curve spans the
#: whole gap (:data:`_CURVE_SPAN`) with no separate platform. It trades against thinness: a higher
#: value holds the tangent flatter for longer (a better-seated node) but steepens the curve's
#: middle to compensate (a thicker centre), while a lower value spreads the drop more evenly
#: (thinner) but tilts the node's tangent (a marker on a slope). Tuned to the balance that keeps
#: the marker's seating close to level while the stroke stays an even, thin arc between nodes.
_BEND_K = 0.4
#: Dots left of our marker the flow arrow sits — one cell, so it embeds in the trunk as ``▶★``
#: and marks the node → us direction without crowding the endpoint.
_ARROW_GAP_DOTS = 2
#: Most distinct paths the lane order is optimised over by exhaustive search. Beyond it the
#: search space (``(n-1)!`` orders of the non-central lanes) is too large, so a barycentre
#: heuristic seats the lanes instead. The widget's real inputs sit far under this.
_MAX_EXACT_LANES = 8
#: Sweeps of the barycentre lane-ordering heuristic used past :data:`_MAX_EXACT_LANES`.
_ORDER_SWEEPS = 8
#: Most non-best routes the side-balancer weighs by exhaustive 2-colouring (see
#: :func:`_balance_sides`). Past it the ``2**m`` side assignments grow too many, so the routes
#: keep the side the jog order handed them. The widget's real inputs sit far under this.
_MAX_BALANCE_ROUTES = 16
#: The glyph used for the flow arrow embedded in the trunk just before us,
#: so the whole flow reads better as a directed run node → us (not a map you wander).
#_ARROW_GLYPH = "▶"
_ARROW_GLYPH = ""
#: How far a layer's emphasis outranks its layout priority when edges compete for a cell.
#: Emphasis is the draw-time highlight — the selected path drawn on top and winning any shared
#: cell — and must beat any spread of priority values, so it is scaled well past the small
#: priorities the widget ever sees. Layout geometry ignores emphasis entirely; only the colour
#: and z-order of the drawn edges follow it (see :func:`_draw_rank`), so re-emphasising a
#: different path repaints the same picture in new colours rather than relaying it.
_EMPHASIS_BOOST = 1_000_000
#: 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 straight across).
color: The edge colour the path draws in.
priority: **Layout** rank — which path is the spine. The highest-priority path takes
the straight centre lane and owns any relay it shares (weaker paths jog to meet it),
so it fixes every node's column and lane. Geometry depends on this alone, never on
:attr:`emphasis`: a caller that wants the drawn picture to hold still while it
re-highlights keeps each path's priority constant.
emphasis: **Draw** rank — the highlight, layered over the fixed geometry. Where edges
share a cell (or cross), the higher-emphasis path wins the colour and draws on top;
it moves no marker. Defaults to ``0`` (all paths equal, so the colour falls to
:attr:`priority` as before). A caller that highlights by selection varies *this*,
not priority, so the layout stays put and only the colours change.
"""
hops: tuple[str, ...]
color: RGB
priority: int
emphasis: int = 0
def _mid_row(y_dot: float) -> 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 lane's y
to :data:`_CELL_MID_DOT` keeps markers — and the level runs along a lane — centred in the
cell's pixel space.
"""
return round((y_dot - _CELL_MID_DOT) / 4) * 4 + _CELL_MID_DOT
def _collapse(layers: Sequence[PathLayer]) -> list[PathLayer]:
"""Fold layers with identical hop sequences onto one drawn path.
Re-walking a known route highlights it rather than doubling it. Geometry keeps the
strongest ``priority`` of the folded copies (so a shared route seats where its best
instance would), while the highlight — the drawn colour and its on-top order — follows the
most-*emphasised* copy, and the emphasis carries across. So two routes that only differ in
a cluster-internal order the caller has contracted away land on one line, and selecting
either lights it, even when a higher-priority copy owns the layout.
"""
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)
else:
cur = drawn[at]
top = layer if _draw_rank(layer) > _draw_rank(cur) else cur
drawn[at] = PathLayer(
cur.hops,
top.color,
max(layer.priority, cur.priority),
max(layer.emphasis, cur.emphasis),
)
return drawn
_HEX_DIGITS = frozenset("0123456789abcdef")
def _coalesce_prefixes(layers: Sequence[PathLayer]) -> list[PathLayer]:
"""Fold an under-specified hop into the longer id it can only be, across the layers.
A relay reaches the graph at different hash widths across the paths — a 1-byte trace
hop (``be``) beside the same node's wider id (``be1d1c1dbc4b``). Drawn as-is they double
the node: two markers a column apart, often carrying the same resolved name. The
observed-topology graph coalesces what it can, but only where a hash is unambiguous
against the *whole* contact list; a hash that opens two contacts (``be`` also begins
``bedd2b``) survives to here, even though among *these* paths it can only be the one wide
node present. So the widget closes the gap locally: using only the ids the layers hold,
a short id is rewritten to the longer id it strictly prefixes when exactly one such id is
present (following a chain to its longest end); a short id that prefixes two distinct
present nodes is genuinely ambiguous and left as itself. The per-node callbacks then see
one id per node, and any paths made identical by the rewrite collapse together downstream.
"""
ids = {hop for layer in layers for hop in layer.hops}
remap: dict[str, str] = {}
remaining = set(ids)
while True:
pair = _prefix_merge(remaining)
if pair is None:
break
short, long = pair
remap[short] = long
remaining.discard(short)
if not remap:
return list(layers)
def resolved(hop: str) -> str:
for _ in range(len(remap) + 1): # follow a rewrite chain, guarded against cycles
if hop not in remap:
break
hop = remap[hop]
return hop
return [
PathLayer(
tuple(resolved(hop) for hop in layer.hops),
layer.color, layer.priority, layer.emphasis,
)
for layer in layers
]
def _prefix_merge(nodes: set[str]) -> Optional[tuple[str, str]]:
"""The next ``(short, long)`` id pair to fold among ``nodes``, or ``None`` when none.
A short hex id (under a full 6-byte width) folds when the longer present ids that
extend it all lie on one prefix chain — the longest starts with every other — so it can
only name that one node. Shortest ids are offered first, collapsing a chain from its end.
"""
for short in sorted(nodes, key=len):
if len(short) >= 12 or not _is_hex(short):
continue
exts = [
other
for other in nodes
if other != short and len(other) > len(short)
and _is_hex(other) and other.startswith(short)
]
if not exts:
continue
longest = max(exts, key=len)
if all(longest.startswith(ext) for ext in exts):
return short, longest
return None
def _is_hex(value: str) -> bool:
"""Whether every character of ``value`` is a hex digit (endpoint sentinels are not)."""
return bool(value) and all(char in _HEX_DIGITS for char in value)
def _draw_rank(layer: PathLayer) -> int:
"""A layer's edge draw rank: emphasis dominates, layout priority breaks ties.
Governs only which colour wins a shared cell and which edge draws on top — never node
placement (that is :attr:`PathLayer.priority` alone). So a caller can re-emphasise a
different path (bump its :attr:`~PathLayer.emphasis`) and the graph repaints in new colours
over the very same layout, rather than reflowing because the spine changed.
"""
return layer.priority + layer.emphasis * _EMPHASIS_BOOST
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,
lane_pitch: int = _LANE_PITCH_ROWS,
) -> list[str]:
"""Draw the diverge/converge route-flow graph and return its ANSI lines.
Args:
layers: The paths to draw. The highest ``priority`` takes the straight centre lane and
fixes the geometry; a separate ``emphasis`` (default ``0``) decides which path is
drawn highlighted — on top, winning any shared cell — without moving a marker, so
re-emphasising a path repaints the same layout in new colours. Layers with identical
hop sequences collapse to one drawn path owned by the highest priority among them.
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 few the lanes.
max_rows: The most canvas rows to spend; a graph with more lanes than fit
compresses its lane spacing rather than growing past this.
lane_pitch: Text rows between adjacent lane markers (so ``lane_pitch - 1`` blank rows
pad each gap). An even lane count opens one extra row between the two central lanes
so the endpoints land on an exact centred row. The row budget compresses the pitch
when there are many lanes, and never stretches it when there are few. Defaults to
:data:`_LANE_PITCH_ROWS`.
Returns:
One ANSI string per canvas row (empty when there are no layers to draw).
"""
if not layers:
return []
drawn = _collapse(_coalesce_prefixes(layers))
seqs = [(SRC_NODE, *layer.hops, DST_NODE) for layer in drawn]
# First-appearance order for every node, so the layout is identical on every repaint: a
# set of hash-seeded string ids would iterate in a run-varying order and let the passes
# settle differently each time, making the graph jump between frames.
ordered_nodes: list[str] = []
seen: set[str] = set()
edges: set[tuple[str, str]] = set()
for seq in seqs:
for node in seq:
if node not in seen:
seen.add(node)
ordered_nodes.append(node)
edges.update(zip(seq, seq[1:]))
xfrac = _balanced_x(ordered_nodes, edges)
# A node draws on its highest-priority owning path — the first (by priority, then
# appearance) to carry it — so a shared relay sits once, on the strongest route through it,
# and weaker routes jog to meet it. A path that introduces no node of its own (every hop
# already owned by a stronger route) is *subsumed*: it earns no lane and simply threads the
# markers others placed, so it costs no empty band.
best = max(range(len(drawn)), key=lambda j: drawn[j].priority)
owner: dict[str, int] = {}
for i in sorted(range(len(drawn)), key=lambda j: -drawn[j].priority):
for node in seqs[i]:
owner.setdefault(node, i)
# A path that is only a detour off a sibling (that sibling's route with an extra relay
# inserted) is subsumed too: its relay is re-owned onto the sibling's lane so it costs no
# band and needs no crossing to rejoin the shared node. See :func:`_fold_detours`.
_fold_detours(drawn, seqs, owner, xfrac, width)
lane_of_path = _assign_lanes(drawn, seqs, owner, best)
# Compress the per-path lanes onto the fewest rows the picture actually needs. A lane per
# path stacks the whole graph as tall as it has routes, even where the routes barely overlap
# — five routes through at most two nodes per column drew five lanes deep. So each off-spine
# node is slid to the innermost free lane *in its own column*, and the alternatives' flanks
# are balanced above/below so no flank stacks deep while the other sits empty: a column
# holding one off-spine node needs one off-spine row however many paths cross the graph, and
# only a column where several routes genuinely stack pays for the extra rows.
span = width * 2 - 2 * _GRAPH_PAD_DOTS
def col_of(node: str) -> int:
return (_GRAPH_PAD_DOTS + round(xfrac[node] * span)) >> 1
signed = _compress_lanes(ordered_nodes, lane_of_path, owner, best, col_of)
# The endpoints sit at the vertical centre of the compressed lane band, where the strongest
# route runs through them as the graph's spine; the alternatives fan above and below. When the
# flanks balance out evenly the spine's own lane *is* that centre and it runs dead straight;
# when the band is lopsided (or even-numbered) the centre falls between lanes, and the best
# path eases gently to reach the endpoints rather than seating the whole graph off-centre —
# a lean the balancer keeps small by flattening the flanks first.
low = min(signed.values(), default=0)
high = max(signed.values(), default=0)
max_lane = high - low
centre_lane = max_lane / 2.0
node_lane: dict[str, float] = {
node: (centre_lane if node in (SRC_NODE, DST_NODE) else float(signed[node] - low))
for node in ordered_nodes
}
# -- Vertical sizing. Each lane sits on its own text row, ``lane_pitch`` rows apart (so
# ``lane_pitch - 1`` blank rows pad each gap). On an *even* lane count the two central lanes
# are opened one extra row apart, so the endpoints — pinned to the band's centre — land on
# the exact middle row between them rather than on a fractional row that would snap off it;
# an *odd* count already seats a central lane there for them to ride. A graph too tall for
# ``max_rows`` scales every row down proportionally (never up), so it stays compact.
even_lanes = max_lane % 2 == 1 # N = max_lane + 1 lanes; even ⟺ max_lane odd
lower_centre = max_lane // 2 + 1 # first lane below the centre (only meaningful when even)
def slot(lane: float) -> float:
"""The text row (pre-scaling) a lane index maps to, with the even-count centre gap."""
rows_out = lane * lane_pitch
if even_lanes and lane > lower_centre - 1:
# the lower-central lane and everything below it are pushed one row down; the
# endpoints' half-lane takes half of it, landing them on the widened gap's middle.
rows_out += min(1.0, lane - (lower_centre - 1))
return rows_out
if max_lane <= 0:
rows = min_rows
lane_rows = 0.0
scale = 0.0
else:
lane_rows = slot(float(max_lane)) # total lane span, in rows
ideal = lane_rows * 4 + 2 * _GRAPH_END_DOTS
rows = max(min_rows, min(max_rows, ceil(ideal / 4)))
avail = rows * 4 - 2 * _GRAPH_END_DOTS
scale = min(1.0, avail / (lane_rows * 4)) if lane_rows else 0.0
canvas = MapCanvas(width, rows)
band = lane_rows * 4 * scale
# Centre the band, anchored on a cell-mid row so the (unscaled) integer lane rows land dead
# on their cells — no per-lane snap drift that would nudge a centred endpoint off its middle.
top = float(_mid_row((rows * 4 - band) / 2))
def x_of(node: str) -> int:
return _GRAPH_PAD_DOTS + round(xfrac[node] * span)
pos: dict[str, tuple[int, int]] = {
node: (x_of(node), _mid_row(top + slot(node_lane[node]) * 4 * scale))
for node in ordered_nodes
}
# -- Edges. Collect every edge once, keyed by its unordered node pair: an edge two routes
# share — or a pair walked in *both* directions — must draw a single time, else it silts up
# as a doubled line a dot off itself (two routes' Bresenham runs never land on the exact
# same dots). Each pair keeps the colour and draw rank of the strongest route through it —
# by draw rank, so the *emphasised* (highlighted) route wins a shared edge over a merely
# higher-priority spine, and the highlight paints the whole selected route rather than
# dropping out where it overlaps another. A two-way pair is flagged so it draws as the one
# honest vertical rather than a lane change.
bidir = {frozenset((u, v)) for (u, v) in edges if (v, u) in edges}
edge_style: dict[frozenset[str], tuple[int, RGB]] = {}
for layer, seq in sorted(zip(drawn, seqs), key=lambda ls: _draw_rank(ls[0])):
rank = _draw_rank(layer)
for u, v in zip(seq, seq[1:]):
key = frozenset((u, v))
prev = edge_style.get(key)
if prev is None or rank > prev[0]:
edge_style[key] = (rank, layer.color)
# Draw ascending by draw rank so the strongest/most-emphasised route's colour wins any cell
# two edges share and sits on top.
for key, (rank, color) in sorted(edge_style.items(), key=lambda kv: kv[1][0]):
u, v = tuple(key)
canvas.draw_line(_route(u, v, pos, key in bidir), color, rank)
# -- An arrow embedded in the trunk just before us, so the whole flow reads as a directed
# run node → us (not a map you wander). A single glyph in the spine's own colour: it reserves
# its cell, so the endpoint label routes around it rather than colliding with stray dots.
ax, ay = pos[DST_NODE]
canvas.marker(ax - _ARROW_GAP_DOTS, ay, _ARROW_GLYPH, drawn[best].color)
# -- Markers for every node (endpoints and relays alike each draw once).
for node in ordered_nodes:
glyph, color_hex = glyph_of(node)
canvas.marker(*pos[node], glyph, parse_hex(color_hex))
_place_labels(canvas, ordered_nodes, pos, node_lane, width, rows * 2, label_of, label_rgb_of)
return canvas.to_ansi_lines()
def _route(
u: str,
v: str,
pos: dict[str, tuple[int, int]],
bidir: bool,
) -> list[tuple[float, float]]:
"""The point chain for one edge (dot coordinates), drawn as multilane-highway flow.
Two nodes on the same lane join with a level run; two on different lanes join with one smooth
**shift** — a bezier S that leaves the first marker level, drifts across the intervening lanes,
and settles level into the second (see :func:`_sbend`), so there is no corner anywhere, only
the eased level→curve→level of the shift. The shift spans the whole column gap
(:data:`_CURVE_SPAN`) rather than a centred stretch flanked by flat platforms: a platform would
meet the climbing curve at a corner, and that corner draws a heavy braille *knee*, so the curve
runs continuously node to node instead and the marker seats on the curve's own level end tangent.
The endpoints, sitting at the centre of the lane band, make the origin's diverging peels and
us's converging merges fall out of this one rule — no endpoint special case. The lone exception
is ``bidir``: a pair walked both ways draws as a single straight segment between the markers (a
near-vertical when the layout stacks them), the one place an up-and-down line is the honest
picture.
"""
(xu, yu), (xv, yv) = pos[u], pos[v]
if bidir:
return [(xu, yu), (xv, yv)]
if xu > xv: # orient the trapezium left→right; balanced rank only ties, never inverts
(xu, yu), (xv, yv) = (xv, yv), (xu, yu)
if yu == yv:
return [(xu, yu), (xv, yv)]
dx = xv - xu
shift = min(dx, max(_MIN_SHIFT_DOTS, round(dx * _CURVE_SPAN)))
stub = (dx - shift) // 2
# A bezier S across the whole gap (level tangents at both ends, so it eases out of and back
# into each marker with no corner — and no platform corner to pile a heavy knee).
return [(xu, yu), *_sbend(xu + stub, yu, xv - stub, yv), (xv, yv)]
def _sbend(
x0: float, y0: float, x1: float, y1: float
) -> list[tuple[float, float]]:
"""Sample a cubic-bezier S-curve from ``(x0, y0)`` to ``(x1, y1)``, level at both ends.
Both control points sit level with their own endpoint (:data:`_BEND_K` of the span in from
each side), so the curve's tangent is horizontal where it meets the platforms — the smooth
lane change that drifts across and settles rather than cutting a hard diagonal. Sampled
densely enough that the polyline rasterizes as a continuous curve; the convex hull keeps it
inside the ``(x0, y0)–(x1, y1)`` box, so it never overshoots its lane or column.
"""
cx0 = x0 + _BEND_K * (x1 - x0)
cx1 = x1 - _BEND_K * (x1 - x0)
samples = max(4, int(abs(x1 - x0) + abs(y1 - y0)))
pts: list[tuple[float, float]] = []
for i in range(samples + 1):
t = i / samples
mt = 1.0 - t
a, b, c, d = mt * mt * mt, 3 * mt * mt * t, 3 * mt * t * t, t * t * t
pts.append((a * x0 + b * cx0 + c * cx1 + d * x1, a * y0 + b * y0 + c * y1 + d * y1))
return pts
def _balanced_x(ordered_nodes: list[str], edges: set[tuple[str, str]]) -> dict[str, float]:
"""Each node's horizontal position in ``0..1`` — balanced rank from origin toward us.
A node's fraction is its longest-path distance from the origin over that distance plus
its longest remaining distance to us: the origin lands at ``0``, us at ``1``, and every
other node between them in proportion to how far along its route it sits. So a path's
relays spread *evenly* between the two ends however many hops the other paths take — a
lone relay on a one-hop route lands mid-canvas rather than jammed against the origin with
a long edge arcing across to us — and a relay shared by routes of different lengths still
resolves to one x. Because every edge steps the from-origin distance up and the
to-us distance down, the fraction rises strictly along each path: edges only ever run
left to right.
A pair walked in *both* directions is a 2-cycle in the edge set, and the longest-path
relaxation would loop through it, inflating every downstream depth toward the node-count
cap and jamming other relays hard against the ends. So the rank runs over the graph with
each such pair (transitively) merged to one representative — the acyclic flow the picture
really is; the pair, drawn as a single vertical, rightly shares an x anyway.
"""
rep = _merge_bidir_pairs(ordered_nodes, edges)
reps: list[str] = []
seen: set[str] = set()
for node in ordered_nodes:
if rep[node] not in seen:
seen.add(rep[node])
reps.append(rep[node])
rep_edges = {(rep[u], rep[v]) for u, v in edges if rep[u] != rep[v]}
up = _longest_paths(reps, rep_edges)
down = _longest_paths(reps, {(v, u) for u, v in rep_edges})
frac = {
r: (up[r] / (up[r] + down[r])) if (up[r] + down[r]) else 0.0 for r in reps
}
return {node: frac[rep[node]] for node in ordered_nodes}
def _merge_bidir_pairs(
ordered_nodes: list[str], edges: set[tuple[str, str]]
) -> dict[str, str]:
"""Map each node to a representative, uniting any two joined by a both-ways edge.
Two nodes walked in both directions form a 2-cycle; uniting them — transitively, so a
chain of such pairs folds into one group — lets the balanced rank treat the flow as the
acyclic run it otherwise is. A node in no such pair maps to itself. The choice of which
member is the representative doesn't matter: every member is assigned the group's one
fraction, and the group's rank is structural.
"""
parent = {node: node for node in ordered_nodes}
def find(node: str) -> str:
root = node
while parent[root] != root:
root = parent[root]
while parent[node] != root: # path-compress
parent[node], node = root, parent[node]
return root
for u, v in edges:
if (v, u) in edges:
ru, rv = find(u), find(v)
if ru != rv:
parent[ru] = rv
return {node: find(node) for node in ordered_nodes}
def bidir_clusters(sequences: Sequence[tuple[str, ...]]) -> list[tuple[str, ...]]:
"""The groups of three or more nodes that form a bidirectional cluster (a flow SCC).
Two nodes walked in both directions are a 2-cycle the graph draws as one tidy vertical
pair — fine on its own. Three or more mutually linked that way are a strongly-connected
knot the left-to-right flow cannot order: :func:`_merge_bidir_pairs` collapses them all
onto one column, where their markers and labels pile up illegibly. A caller can pass its
path sequences (endpoints included) here to find those knots and contract each to a single
super-node *before* drawing, so the cluster reads as one marker rather than a jam — the one
honest way to seat a cycle in a DAG layout.
Returns each cluster's member ids in first-appearance order (endpoints excluded); a lone
node or the tidy two-node pair is not a cluster and is not returned.
"""
edges = {pair for seq in sequences for pair in zip(seq, seq[1:])}
ordered = list(dict.fromkeys(node for seq in sequences for node in seq))
rep = _merge_bidir_pairs(ordered, edges)
groups: dict[str, list[str]] = {}
for node in ordered:
if node in (SRC_NODE, DST_NODE):
continue
groups.setdefault(rep[node], []).append(node)
return [tuple(members) for members in groups.values() if len(members) >= 3]
def _longest_paths(ordered_nodes: list[str], edges: set[tuple[str, str]]) -> dict[str, int]:
"""Longest-path depth of each node over ``edges`` (relaxed to a fixed point).
Capped at the node count so a pathological cycle in the id set can't spin it forever;
the graphs are acyclic fans, so it settles in a couple of passes.
"""
depth = {node: 0 for node in ordered_nodes}
for _ in range(len(ordered_nodes)):
changed = False
for u, v in edges:
if depth[v] < depth[u] + 1:
depth[v] = depth[u] + 1
changed = True
if not changed:
break
return depth
def _fold_detours(
drawn: Sequence[PathLayer],
seqs: Sequence[tuple[str, ...]],
owner: dict[str, int],
xfrac: dict[str, float],
width: int,
) -> None:
"""Re-own a *detour* path's extra relays onto the sibling lane it converges into.
A route heard as another route *plus* an inserted relay or two — same convergence into us,
one extra hop on the way — shouldn't cost a whole extra lane. Given its own lane it would
sit on the far side of the best-centred spine from the sibling it rejoins, dragging its
inserted relay clear across the graph to meet the shared node (an avoidable crossing).
So when a bearing path's relays, minus the ones it alone owns, exactly match a
higher-or-equal-priority sibling's relays — and that sibling truly owns them (is their
lane) — the path is that sibling with a detour. Its owned relays are re-owned to the
sibling, riding its lane as waypoints the branch dips through, provided none shares a cell
column with a node *already on that lane* — including a detour folded there earlier, so two
siblings that insert a relay at the same column don't overprint (the second keeps its own
lane). The detour path then owns nothing, earns no lane, and reads as a branch *off* its
sibling instead of a track that crosses the graph to reach it. Mutates ``owner`` in place.
"""
span = width * 2 - 2 * _GRAPH_PAD_DOTS
def col(node: str) -> int:
return (_GRAPH_PAD_DOTS + round(xfrac[node] * span)) >> 1
relays = [
frozenset(n for n in seq if n not in (SRC_NODE, DST_NODE)) for seq in seqs
]
# Weakest first, so a marginal detour folds onto its stronger sibling, never the reverse.
for i in sorted(range(len(drawn)), key=lambda j: drawn[j].priority):
own_i = {n for n in seqs[i] if owner[n] == i}
residual = relays[i] - own_i
if not own_i or not residual:
continue
for q in range(len(drawn)):
if q == i or drawn[q].priority < drawn[i].priority or relays[q] != residual:
continue
if not all(owner[n] == q for n in relays[q]): # q must own (be the lane of) them
continue
# Columns already committed on q's lane (its own relays plus any earlier fold).
q_cols = {
col(n) for n, o in owner.items() if o == q and n not in (SRC_NODE, DST_NODE)
}
new_cols = {col(n) for n in own_i}
if len(new_cols) == len(own_i) and q_cols.isdisjoint(new_cols):
for n in own_i:
owner[n] = q
break
def _assign_lanes(
drawn: Sequence[PathLayer],
seqs: Sequence[tuple[str, ...]],
owner: dict[str, int],
best: int,
) -> dict[int, int]:
"""Seat each lane-bearing path on a horizontal lane, best centred, sharing routes close.
Returns ``{path_index: lane}`` — only for the paths that own at least one node (a subsumed
path threads others' markers and needs no lane of its own) — with lanes ``0`` (top) up. The
best path is pinned to the centre lane, where it runs straight through the two endpoints as
the graph's spine; the rest are ordered to minimise the total *jog* — the vertical distance
a path travels to reach a relay another path owns — so routes that share hops sit near each
other and a shared relay costs the shortest detour. Small graphs
(``≤`` :data:`_MAX_EXACT_LANES` lanes) get the exact best order by search; larger ones fall
back to a barycentre heuristic.
"""
bearing = [i for i in range(len(drawn)) if any(owner[node] == i for node in seqs[i])]
n = len(bearing)
if n == 1:
return {bearing[0]: 0}
# Each bearing path's jog partners: the owning lanes of the hops it borrows from others.
shared_owners = {
i: [owner[node] for node in seqs[i] if owner[node] != i] for i in bearing
}
def jog(lane: dict[int, int]) -> int:
return sum(abs(lane[i] - lane[o]) for i in bearing for o in shared_owners[i])
centre_slot = (n - 1) // 2
others = [i for i in bearing if i != best]
if n - 1 <= _MAX_EXACT_LANES:
best_order: Optional[tuple[int, ...]] = None
best_cost: Optional[int] = None
for perm in permutations(others):
slots = list(perm)
slots.insert(centre_slot, best)
cost = jog({p: k for k, p in enumerate(slots)})
if best_cost is None or cost < best_cost:
best_cost, best_order = cost, tuple(slots)
assert best_order is not None
return {p: k for k, p in enumerate(best_order)}
return _barycentre_lanes(bearing, best, centre_slot, shared_owners)
def _barycentre_lanes(
bearing: list[int],
best: int,
centre_slot: int,
shared_owners: dict[int, list[int]],
) -> dict[int, int]:
"""Heuristic lane order for a graph too large to search: barycentre sweeps.
Each path is repeatedly re-seated at the average lane of the paths it shares relays with,
with the best path pinned to the centre lane. A handful of sweeps settles sharing routes
next to each other — the cheap crossing-minimiser, at the coarser grain of whole lanes.
"""
lane = {p: float(k) for k, p in enumerate(bearing)}
lane[best] = float(centre_slot)
others = [i for i in bearing if i != best]
for _ in range(_ORDER_SWEEPS):
for i in others:
if shared_owners[i]:
lane[i] = sum(lane[o] for o in shared_owners[i]) / len(shared_owners[i])
order = sorted(others, key=lambda i: lane[i])
order.insert(centre_slot, best)
lane = {p: float(k) for k, p in enumerate(order)}
return {p: int(lane[p]) for p in order}
def _compress_lanes(
ordered_nodes: list[str],
lane_of_path: dict[int, int],
owner: dict[str, int],
best: int,
col_of: Callable[[str], int],
) -> dict[str, int]:
"""Squeeze the per-path lanes onto the fewest rows, one node at a time within each column.
:func:`_assign_lanes` seats each path on a lane of its own, so the band is as tall as the
graph has routes — even where the routes only ever run one or two abreast. But a lane is a
whole-width row: two routes need distinct rows only in the *columns* where they both carry a
node, and endpoint-to-endpoint every route already shares the origin and us. So this pass
keeps the best path's relays on the spine (offset ``0``) and slides every other node to the
innermost free lane *above or below* it *in its own column*: a column with one node above
the spine uses the first row above however many routes fan past it, and only a column where
several routes truly stack claims the deeper rows.
Which **side** each alternative takes is not inherited from its jog-order lane — that seats
routes sharing a relay *adjacent*, which piles column-sharing routes onto the *same* flank
and leaves the band as tall as one flank's deepest stack plus the other's, even when no
single column holds more than two nodes. Instead :func:`_balance_sides` 2-colours the
alternatives to flatten the deeper flank (the spine may lean off dead-centre to reach the
band's middle, which is fine), so a five-route fan through at most two nodes per column draws
three lanes — spine plus one flank each side — not five. The best path's relays still hold
the straight spine; the returned lanes are signed offsets from it (``<0`` above, ``>0``
below), which the caller shifts to a ``0``-based band.
Returns ``{node: signed_lane}`` for every relay (endpoints are placed on the band centre by
the caller, not here). A node a column holds alone off the spine always lands on ``±1``, so
a sparse multi-route graph collapses to the three-lane spine-and-two-flanks it really is.
"""
best_lane = lane_of_path[best]
relays = [node for node in ordered_nodes if node not in (SRC_NODE, DST_NODE)]
spine = {node for node in relays if owner[node] == best}
off_spine = [node for node in relays if node not in spine]
# The non-best bearing routes, in the jog-order _assign_lanes settled (each route keyed by
# its signed lane relative to best), so ties fall to the arrangement that already reads clean.
routes = sorted(
{owner[node] for node in off_spine},
key=lambda r: (lane_of_path[r] - best_lane, r),
)
cols_of_route = {
r: {col_of(node) for node in off_spine if owner[node] == r} for r in routes
}
orig_side = {r: (1 if lane_of_path[r] - best_lane > 0 else -1) for r in routes}
side = _balance_sides(routes, cols_of_route, orig_side)
rank = {r: i for i, r in enumerate(routes)} # nearest-to-spine order within a flank
columns: dict[int, list[str]] = {}
for node in off_spine:
columns.setdefault(col_of(node), []).append(node)
signed: dict[str, int] = {node: 0 for node in spine}
for members in columns.values():
above = sorted((n for n in members if side[owner[n]] < 0), key=lambda n: rank[owner[n]])
below = sorted((n for n in members if side[owner[n]] > 0), key=lambda n: rank[owner[n]])
for depth, node in enumerate(above, start=1):
signed[node] = -depth
for depth, node in enumerate(below, start=1):
signed[node] = depth
return signed
def _balance_sides(
routes: list[int],
cols_of_route: dict[int, set[int]],
orig_side: dict[int, int],
) -> dict[int, int]:
"""Choose a flank (``-1`` above / ``+1`` below the spine) for each alternative route.
The band a :func:`_compress_lanes` pack draws is ``(deepest stack above) + (deepest stack
below) + 1``: two alternatives need distinct rows only where they share a column, so a flank
is only as deep as its most-crowded column. The jog order that seats sharing routes adjacent
puts them on the *same* flank, which can stack one flank two deep while the other sits empty
— a needlessly tall band. So the routes are 2-coloured, exhaustively while they are few
(:data:`_MAX_BALANCE_ROUTES`; past it the jog-order sides stand):
* never above the **ceiling** the jog order itself draws — balancing may flatten a band, never
grow one. A column that genuinely stacks three nodes forces one flank two deep whatever the
colouring, and filling the other flank to match it (prettier, but taller) is refused;
* under that ceiling, **flatten the deeper flank**, so a fan piled two deep on one side while
the other is empty splits across both and the band shrinks;
* then even the two flanks, then keep the jog-order side. So a graph the jog order already
draws flat is left exactly as it is — its lower-band one-sided alternative has the *same*
deepest flank, so the balance tie-break holds it airy rather than lopsiding it a row shorter
— while a graph with a needlessly deep flank is the one that actually moves.
Returns ``{route_index: side}``.
"""
if not routes:
return {}
if len(routes) > _MAX_BALANCE_ROUTES:
return dict(orig_side)
def flanks(assign: dict[int, int]) -> tuple[int, int]:
above: dict[int, int] = {}
below: dict[int, int] = {}
for r in routes:
stack = above if assign[r] < 0 else below
for col in cols_of_route[r]:
stack[col] = stack.get(col, 0) + 1
return max(above.values(), default=0), max(below.values(), default=0)
orig_above, orig_below = flanks(orig_side)
ceiling = orig_above + orig_below # the jog order's band height − 1; never draw taller
best_assign: Optional[dict[int, int]] = None
best_key: Optional[tuple[int, int, int]] = None
for combo in product((-1, 1), repeat=len(routes)):
assign = dict(zip(routes, combo))
deep_above, deep_below = flanks(assign)
if deep_above + deep_below > ceiling: # taller than the jog order would draw — reject
continue
agree = sum(1 for r in routes if assign[r] == orig_side[r])
key = (max(deep_above, deep_below), abs(deep_above - deep_below), -agree)
if best_key is None or key < best_key:
best_key, best_assign = key, assign
assert best_assign is not None # orig_side always meets its own ceiling
return best_assign
def _place_labels(
canvas: MapCanvas,
ordered_nodes: list[str],
pos: dict[str, tuple[int, int]],
node_lane: dict[str, float],
width: int,
mid_y: int,
label_of: LabelOf,
label_rgb_of: LabelRgbOf,
) -> None:
"""Write each node's label above or below its marker, clear of the lines if it can.
Endpoints first, then relays down the lanes, so the named ends win any contest for a
cell. Each label is tried on the side away from the graph's middle first (spreading the
text outward off the busy centre), preferring a row the drawn lines don't already occupy.
Any node hard against a canvas edge — an endpoint by construction, or a relay a balanced
rank pins near the origin or us — slides its centre anchor inward far enough for the whole
name to land, since a centred label overhanging the edge places nothing and would leave the
marker silently unlabelled. When both stacked rows are blocked the label falls back to
sitting beside its marker. A name is only ever shortened when it is wider than the entire
canvas.
"""
endpoints = [n for n in (DST_NODE, SRC_NODE) if n in pos]
relays = sorted(
(n for n in ordered_nodes if n not in (SRC_NODE, DST_NODE)),
key=lambda n: (node_lane[n], pos[n][0]),
)
for node in [*endpoints, *relays]:
label = label_of(node)
if not label:
continue
if len(label) > width:
label = label[: max(1, width - 1)] + "…"
rgb = label_rgb_of(node)
x, y = pos[node]
# Clamp the centre cell so the whole label fits between the canvas edges: a marker near
# an edge would otherwise centre its name off-canvas, place nothing, and be dropped. A
# marker with room to spare keeps its true centre (the clamp is a no-op there).
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 <= mid_y 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
# Both rows blocked: sit the label beside the marker (clear of the drawn lines if it
# can, else over them) rather than drop it.
if not canvas.marker_label(x, y, label, rgb, avoid_dots=True):
canvas.marker_label(x, y, label, rgb)