Path graph: centre the endpoints on a row-quantized lane layout

The origin and destination rode the best path's discrete lane, which for
an even lane count is the upper of the two central lanes — so they, and
the spine, sat half a lane above centre. Anchor them at the band's
geometric centre (max_lane / 2.0) instead: identical to the best lane
for odd counts (spine stays dead straight), the midpoint of the two
central lanes for even counts.

Back that with a row-quantized vertical layout so the centring is exact.
Lanes now sit on integer text rows a fixed pitch apart (_LANE_PITCH_ROWS
= 3, two blank padding rows per gap) rather than a continuous dot step;
on an even count the two central lanes are opened one extra row apart, so
the endpoints land on the exact middle row between them instead of a
fractional row that snapped off centre. The band's top is anchored to a
cell-mid row so integer lane rows land dead-on with no per-lane snap
drift.

The public lane_step (dots) parameter becomes lane_pitch (rows); the
node-detail override is dropped so that page shares the 2-row pitch,
keeping only its larger max_rows for busy nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-21 23:47:33 -04:00
1 parent 25922efb0b
commit 8f6fa6b6ae
3 files changed
+239 -35

No files matched your search

+6 -7
View File
@@ -74,12 +74,12 @@ _MAP_ROWS = 7
#: Route-graph tuning for this page. It draws the contact on the left and us on the right
#: (node → us, so the graph reads left to right as the inbound direction its packets
#: travelled to reach us). A well-connected node offers several candidate paths at once, and
#: cramming them into a short box compresses the lanes together — so this page gives each lane
#: a generous step and row budget, letting each route spread into its own clearly separated
#: track. The height is adaptive: it grows only with the number of distinct
#: lanes, so a node with one or two routes still draws compact while a busy one earns the room
#: it needs. The page scrolls (PgUp/PgDn), so a tall graph never crowds the action rows off.
_PATH_LANE_STEP = 15
#: cramming them into a short box compresses the lanes together — so this page keeps the shared
#: lane pitch but grants a generous row budget, letting a busy node spread each route into its
#: own clearly separated track instead of scaling the pitch down. The height is adaptive: it
#: grows only with the number of distinct lanes, so a node with one or two routes still draws
#: compact while a busy one earns the room it needs. The page scrolls (PgUp/PgDn), so a tall
#: graph never crowds the action rows off.
_PATH_MAX_ROWS = 22
#: A drawn route is dropped as *stale* when its freshest-limiting link — the stalest hop it
@@ -287,7 +287,6 @@ class NodeDetailScreen(Screen):
label_of=path.label_of, # type: ignore[arg-type]
label_rgb_of=path.label_rgb_of, # type: ignore[arg-type]
max_rows=_PATH_MAX_ROWS,
lane_step=_PATH_LANE_STEP,
)
caption = Text("node → you, as heard · white = best route", style="faint")
lines.extend(render_lines(caption, width, no_wrap=True))
+117 -28
View File
@@ -15,11 +15,14 @@ now draws the fan as a **multilane highway**: a node always sits on a level plat
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 stays there. Concretely:
* **lanes** — each distinct path owns one horizontal lane; the highest-priority (drawn last,
e.g. the selected or best-evidence) path takes the centre lane, running dead straight
through the two endpoints as the flow's spine, and the alternatives lie above and below it.
The lanes are ordered to seat routes that share relays near each other, so a shared hop
costs the shortest possible detour;
* **lanes** — each distinct path owns one horizontal lane, the lanes a fixed pitch of text rows
apart; the highest-priority (drawn last, e.g. the selected or best-evidence) path takes the
centre lane and the alternatives lie above and below it. The two endpoints sit at the vertical
centre of the lane band, so the strongest route runs through them as the flow's spine — dead
straight when the lanes are odd (its own lane is the centre), easing gently when they are even,
where one extra padding row is opened between the two central lanes so the endpoints land on an
exact centred row between them. The lanes are ordered to seat routes that share relays near
each other, so a shared hop costs the shortest possible detour;
* **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;
@@ -62,12 +65,14 @@ from .mapcanvas import RGB, MapCanvas, parse_hex
SRC_NODE = "\x00src"
DST_NODE = "\x00dst"
#: Vertical dot separation aimed for between adjacent lanes — the base height one lane maps
#: to. Wide enough to seat a marker and its label row; a graph with many lanes compresses it
#: to fit the row budget, and a graph with few lanes never stretches *past* it (spreading the
#: lanes further apart would only reintroduce the empty space the layout exists to avoid), so
#: a sparse graph draws compact rather than splayed.
_LANE_STEP_DOTS = 14
#: 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.
@@ -89,7 +94,7 @@ _GRAPH_PAD_DOTS = 6
#: the curve as a fraction of the gap — rather than by a fixed slope keyed off the vertical
#: offset — keeps the platform-to-curve proportion constant however wide or narrow the columns
#: fall.
_CURVE_SPAN = 0.75
_CURVE_SPAN = 0.8
#: 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.
@@ -259,7 +264,7 @@ def render_path_graph(
label_rgb_of: LabelRgbOf,
min_rows: int = 5,
max_rows: int = 15,
lane_step: int = _LANE_STEP_DOTS,
lane_pitch: int = _LANE_PITCH_ROWS,
) -> list[str]:
"""Draw the diverge/converge route-flow graph and return its ANSI lines.
@@ -277,9 +282,11 @@ def render_path_graph(
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_step: Vertical dot separation aimed for between lanes. A larger step opens the
lanes apart; the row budget compresses it when there are many lanes, and never
stretches past it when there are few. Defaults to :data:`_LANE_STEP_DOTS`.
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).
@@ -315,39 +322,68 @@ def render_path_graph(
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)
# The endpoints sit on the best path's lane, where the strongest route runs dead straight
# across as the graph's spine; the alternatives fan above and below it.
# The endpoints sit at the vertical centre of the lane band, where the strongest route runs
# through them as the graph's spine; the alternatives fan above and below. For an odd lane
# count that centre *is* the best path's own lane, so the spine runs dead straight; for an
# even count it is the midpoint between the two central lanes (the best path takes the upper
# of the two), so the endpoints stay centred and a best path carrying relays of its own eases
# half a lane to reach them rather than seating the whole graph off-centre.
max_lane = max(lane_of_path.values())
centre_lane = float(lane_of_path[best])
centre_lane = max_lane / 2.0
node_lane: dict[str, float] = {
node: (centre_lane if node in (SRC_NODE, DST_NODE) else float(lane_of_path[owner[node]]))
for node in ordered_nodes
}
# -- Vertical sizing: size the rows to the lanes, compressing the spacing (never
# stretching it past ``lane_step``) so a busy graph fits and a sparse one stays compact.
# -- 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:
step = 0.0
rows = min_rows
lane_rows = 0.0
scale = 0.0
else:
ideal = max_lane * lane_step + 2 * _GRAPH_END_DOTS
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
step = min(float(lane_step), avail / max_lane)
scale = min(1.0, avail / (lane_rows * 4)) if lane_rows else 0.0
canvas = MapCanvas(width, rows)
dot_w = width * 2
span = dot_w - 2 * _GRAPH_PAD_DOTS
band = max_lane * step
top = (rows * 4 - band) / 2 # centre the lane band in the chosen 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 + node_lane[node] * step)) for node in ordered_nodes
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
@@ -398,7 +434,7 @@ def _route(
:data:`_CURVE_SPAN` of the column gap (a gentle drift, not a slope keyed off the drop) and
sits centred in it, so the platforms flank it evenly; when the gap is too tight to hold a
curve of even :data:`_MIN_SHIFT_DOTS`, the bend simply spans the whole gap. The endpoints,
sitting on the centre lane,
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
@@ -524,6 +560,59 @@ def _longest_paths(ordered_nodes: list[str], edges: set[tuple[str, str]]) -> dic
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, ...]],
+116
View File
@@ -12,6 +12,8 @@ from meshterm.ui.pathgraph import (
PathLayer,
_balanced_x,
_coalesce_prefixes,
_collapse,
_fold_detours,
_route,
render_path_graph,
)
@@ -133,6 +135,49 @@ def test_a_shared_relay_draws_a_single_marker() -> None:
assert plain.count("aa") == 1 and plain.count("cc") == 1
def _marker_rows(lines, glyph): # noqa: ANN001, ANN202
"""The distinct canvas row indices a marker glyph is drawn on (endpoints ``★``, relays ``●``)."""
return sorted({i for i, ln in enumerate(_ANSI.sub("", "\n".join(lines)).splitlines()) if glyph in ln})
def test_even_lane_count_centres_the_endpoints() -> None:
"""With an even number of lanes the origin and destination sit exactly midway between them.
Two disjoint one-relay routes make two lanes — an upper and a lower. The endpoints ride
neither: an extra padding row is opened between the two central lanes so they land on the
exact centred row, rather than pinned to the upper lane (where the best path's discrete lane
would put it) or snapped a row off centre.
"""
layers = [
PathLayer(("aa",), WHITE, 4), # best -> upper lane
PathLayer(("bb",), GREY, 2), # -> lower lane
]
lines = _render(layers, label_of=lambda _n: None) # markers only — no label rows to muddy
(star,) = _marker_rows(lines, "★") # both endpoints share the one centre row
top, bottom = _marker_rows(lines, "●") # the two relays sit on their own lanes
assert top < star < bottom # centred between the lanes, not pinned to either
assert star - top == bottom - star # exact integer centring, thanks to the extra pad row
def test_odd_lane_count_seats_endpoints_on_the_straight_middle_lane() -> None:
"""An odd lane count gives the endpoints a real middle lane to ride — spine dead straight.
Three disjoint one-relay routes make three evenly spaced lanes; the best path's relay owns
the centre lane. The origin and destination share that centre row (no bend, no extra pad
row), sitting midway between the two outer lanes.
"""
layers = [
PathLayer(("mm",), WHITE, 4), # best -> centre lane
PathLayer(("aa",), GREY, 2), # -> a flanking lane
PathLayer(("bb",), GREY, 1), # -> the other flanking lane
]
lines = _render(layers, label_of=lambda _n: None)
(star,) = _marker_rows(lines, "★")
top, mid, bottom = _marker_rows(lines, "●") # three relays, one per lane
assert star == mid # endpoints ride the centre lane — same row as the best relay
assert mid - top == bottom - mid # lanes evenly spaced: no extra gap on an odd count
def test_every_label_shows_on_a_busy_graph() -> None:
"""Four distinct routes lay out as separated lanes — every relay label lands, none dropped."""
names = {
@@ -150,6 +195,77 @@ def test_every_label_shows_on_a_busy_graph() -> None:
assert name in plain, f"{name} was dropped from the graph"
def _owner_after_fold(layers, width=72): # noqa: ANN001
"""Build the per-node owner map and run the detour fold, as ``render_path_graph`` does."""
drawn = _collapse(_coalesce_prefixes(layers))
seqs = [(SRC_NODE, *layer.hops, DST_NODE) for layer in drawn]
ordered: 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.append(node)
edges.update(zip(seq, seq[1:]))
xfrac = _balanced_x(ordered, edges)
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)
_fold_detours(drawn, seqs, owner, xfrac, width)
lanes = sum(1 for i in range(len(drawn)) if any(owner[n] == i for n in seqs[i]))
return owner, lanes
def test_detour_path_rides_its_siblings_lane_not_a_new_one() -> None:
"""A route that is a sibling *plus* one inserted relay folds onto that sibling's lane.
``cc`` inserts a relay before the ``bb`` its sibling also converges through. Given its own
lane on the far side of the best spine, it would drag that relay clear across the graph to
rejoin ``bb`` (the crossing). Folded, ``cc`` is re-owned to ``bb``'s lane — two lanes, no
crossing. This is the real YUL-Poly shape: CDN-FENDALL1 → UpperSalaberry.
"""
layers = [
PathLayer(("xx",), WHITE, 3), # best spine: SRC -> xx -> DST
PathLayer(("bb",), GREY, 2), # sibling: SRC -> bb -> DST
PathLayer(("cc", "bb"), GREY, 2), # detour: SRC -> cc -> bb -> DST
]
owner, lanes = _owner_after_fold(layers)
assert owner["cc"] == owner["bb"] # cc rides bb's lane rather than earning its own
assert lanes == 2
def test_disjoint_routes_are_never_folded() -> None:
"""Routes that share no convergence relay each keep their own lane — folding stays targeted."""
layers = [
PathLayer(("xx",), WHITE, 3),
PathLayer(("aa",), GREY, 2),
PathLayer(("bb",), GREY, 2),
]
_owner, lanes = _owner_after_fold(layers)
assert lanes == 3
def test_two_detours_sharing_a_column_do_not_overprint() -> None:
"""Two siblings inserting a relay at the same column can't both fold onto the shared lane.
``cc`` and ``dd`` each sit one hop off the origin feeding the same ``bb``, so they share a
column; folding both onto ``bb``'s lane would stack two markers in one cell. Exactly one
folds; the other keeps a lane of its own.
"""
layers = [
PathLayer(("xx",), WHITE, 3),
PathLayer(("bb",), GREY, 2),
PathLayer(("cc", "bb"), GREY, 2),
PathLayer(("dd", "bb"), GREY, 1),
]
owner, lanes = _owner_after_fold(layers)
folded = [n for n in ("cc", "dd") if owner[n] == owner["bb"]]
assert len(folded) == 1 # only one rides bb's lane; the other stays separate
assert lanes == 3
def test_edge_pinned_relay_labels_are_not_dropped() -> None:
"""A relay a balanced rank pins near a canvas edge still shows its whole name.