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>
976 lines
50 KiB
Python
976 lines
50 KiB
Python
"""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)
|