From 8f6fa6b6aeb297ba03f6ad92ef0e6facb737ace7 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 21 Jul 2026 22:45:47 -0400 Subject: [PATCH] Path graph: centre the endpoints on a row-quantized lane layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- meshterm/ui/node_detail_screen.py | 13 ++- meshterm/ui/pathgraph.py | 145 ++++++++++++++++++++++++------ tests/test_pathgraph.py | 116 ++++++++++++++++++++++++ 3 files changed, 239 insertions(+), 35 deletions(-) diff --git a/meshterm/ui/node_detail_screen.py b/meshterm/ui/node_detail_screen.py index a3f6541..c7f14bb 100644 --- a/meshterm/ui/node_detail_screen.py +++ b/meshterm/ui/node_detail_screen.py @@ -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)) diff --git a/meshterm/ui/pathgraph.py b/meshterm/ui/pathgraph.py index e0343ae..b0bdbc5 100644 --- a/meshterm/ui/pathgraph.py +++ b/meshterm/ui/pathgraph.py @@ -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, ...]], diff --git a/tests/test_pathgraph.py b/tests/test_pathgraph.py index 5c1e635..edf5af7 100644 --- a/tests/test_pathgraph.py +++ b/tests/test_pathgraph.py @@ -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.