mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:21:58 +00:00
75be5f485e
原来所有边都交给 React Flow 的 smoothstep,它的竖直段一律落在源和目标的
正中间。同一列的边于是全挤在同一个 x 上:一张"入口扇出三路 → 归并 → 分支
→ 出具/驳回"的图上,跨层的 a3→join 和 m2→join 的横段**完全压在同一条线
上**,长边还直接从"归并B"卡片中间穿过去。排版那边同样糙:同层按 id 字典序
竖排,所以 out_no 排在 out_yes 上面("n" < "y"),分支的 yes/no 是反的;
层内没有任何减少交叉的排序;层间距写死 300。
后端 app/engine/layout.py 重写成分层布局:先剔回边再按最长路径分层(loop
的回边不参与分层,否则循环体会被压到循环节点左边);层内中位数启发式上下扫
六轮取交叉最少的一版,分支/循环的出口再按声明顺序占位重排;y 先均匀堆叠再按
邻居重心迭代松弛,同列节点因此和上下游对齐,长边也走得直;最后按"穿过这条
走廊的连线在竖直方向最多重叠几条"给走廊留宽——区间图上的贪心染色,和前端
用的是同一套,走廊留窄了前端再聪明也只能把线叠起来。
前端 canvas/routing.ts 自己算走线:出边沿右边缘错开、入边沿左边缘错开(入边
按上游纵向位置排序),竖直段按区间染色分配车道——真叠在一起的才各占一条,
首尾相接的(同一个扇出点)共用一条,看起来就是一条总线;跨层的长边和循环的
回边在中间各列的纵向占用里找空档绕过去,不再压过卡片。边换成自定义组件后
React Flow 不再自动加动画类,实测确认 .edge-active 仍然生效。
顺带修掉"排完版看不见图":整张图换过坐标后(自动排版 / Copilot 生成完 /
切图)自动重新取景。坐标变了视角没变,人盯着的是一片空白。
新增 scripts/check-canvas-layout.mjs:把夹具图交给真后端 /api/copilot/layout
排版,再直接 import('/src/canvas/routing.ts') 用页面上跑的那份代码算路径,
按几何断言没有两条线压在同一段上、没有线穿过别人的卡片、两端都接在节点边缘,
另有 1325 条随机坐标的 fuzz 防手拖之后算出 NaN 路径(NaN 的 path 什么都不
画,界面上就是"线没了",一声不吭)。这条检查做过变异验证——把 laneX 改成
永远返回走廊中点,它立刻报 a3→join × m2→join,正是原图上那处重叠。
285 passed(新增 12 条排版不变量)。check-ui 69 项通过,唯一失败项"本地哈希
要标出没有语义能力"已 git stash 到基线复现,与本次无关。
415 lines
16 KiB
Python
415 lines
16 KiB
Python
"""画布自动排版。
|
|
|
|
模型生成的图只有拓扑、没有坐标,全堆在原点就没法看;用户手拖过之后点
|
|
「自动排版」也得重新摆一遍。这里做的是分层布局(Sugiyama 那条路)的简化版,
|
|
四步:
|
|
|
|
1. **分层**:按最长路径定层。环上的回边先剔掉——不剔的话 loop 节点的回边
|
|
会把整张图压成一层,循环体跑到循环节点左边去。
|
|
2. **层内排序**:中位数启发式上下扫,取交叉最少的那一版。并行分支因此会按
|
|
上游的相对位置排好,而不是按 id 字典序乱排(以前 `out_no` 排在 `out_yes`
|
|
上面,因为 "n" < "y"——分支的 yes/no 就反着画)。
|
|
3. **定 y**:先均匀堆叠,再按邻居重心迭代松弛。这一步是"同一列的节点和上下游
|
|
对齐"的关键,也是长边能走直、少拐弯的前提。
|
|
4. **定 x**:层与层之间留一条走廊,走廊宽度按穿过它的连线在竖直方向的最大
|
|
重叠数算。前端拿这个宽度给每条线分配独立的竖直车道,线才不会叠在一起。
|
|
——走廊留窄了,前端再聪明也只能把线叠起来。
|
|
|
|
坐标的约定和前端 `canvas/routing.ts` 是一份契约,改这里要同步改那边:
|
|
节点宽 238(NodeCard 写死的),同列节点最小间距 ≥ MIN_ROW_GAP,走廊里
|
|
竖直车道间距 LANE。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict, deque
|
|
from statistics import median
|
|
|
|
from app.engine.schema import GraphNode, GraphSpec, NodeType
|
|
|
|
#: 画布节点的宽度。前端 NodeCard 的 `width: 238` 是写死的,这里必须跟着它——
|
|
#: 排版算出来的走廊是给连线用的,宽度估错了走廊就白留。
|
|
NODE_W = 238
|
|
#: 节点高度估算:标题栏 + 两行摘要。真实高度由前端量,这里只要不低估到让
|
|
#: 同列节点撞上就行(前端按实测高度铺端口,能自己吸收这点误差)。
|
|
NODE_H = 76
|
|
#: 同列相邻节点的最小空隙。除了不撞,还要给横向走线留出能穿过去的空档。
|
|
MIN_ROW_GAP = 56
|
|
#: 连线离开节点后的第一段水平长度。
|
|
STUB = 14
|
|
#: 同一条走廊里相邻竖直车道的间距。
|
|
LANE = 18
|
|
#: 竖直区间重叠多少以内算"同一条道"。共用扇出点的两条线首尾相接,不该各占一条。
|
|
LANE_OVERLAP_TOLERANCE = 6.0
|
|
#: 走廊宽度下限/上限。上限是防止一个 20 路扇出的节点把整张图撑到屏幕外;
|
|
#: 顶到上限时前端会按可用宽度压缩车道间距。
|
|
CORRIDOR_MIN = 96
|
|
CORRIDOR_MAX = 420
|
|
X_ORIGIN = 80
|
|
|
|
#: 分支节点的兜底出口,和前端 `sourceHandles()` 里的写法一致
|
|
BRANCH_FALLBACK = "default"
|
|
|
|
|
|
def _height(node: GraphNode) -> float:
|
|
return NODE_H
|
|
|
|
|
|
def _dedupe_edges(spec: GraphSpec, nodes: dict[str, GraphNode]) -> list:
|
|
"""丢掉悬空边和重复边,顺便给每条边定一个稳定的身份。
|
|
|
|
自环(source == target)留到最后单独处理,它不参与分层。
|
|
"""
|
|
seen: set[tuple[str, str, str]] = set()
|
|
out = []
|
|
for e in spec.edges:
|
|
if e.source not in nodes or e.target not in nodes:
|
|
continue
|
|
key = (e.source, e.target, e.sourceHandle or "")
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(e)
|
|
return out
|
|
|
|
|
|
def _back_edges(nodes: dict[str, GraphNode], edges: list) -> set[tuple[str, str, str]]:
|
|
"""DFS 森林里的回边。剔掉它们,剩下的必然是无环的。
|
|
|
|
迭代式而不是递归:一张几百个节点的图不该因为递归深度挂掉。
|
|
"""
|
|
adj: dict[str, list] = defaultdict(list)
|
|
for e in edges:
|
|
adj[e.source].append(e)
|
|
|
|
WHITE, GREY, BLACK = 0, 1, 2
|
|
state = dict.fromkeys(nodes, WHITE)
|
|
back: set[tuple[str, str, str]] = set()
|
|
|
|
for root in nodes:
|
|
if state[root] != WHITE:
|
|
continue
|
|
stack: list[tuple[str, int]] = [(root, 0)]
|
|
state[root] = GREY
|
|
while stack:
|
|
node_id, i = stack[-1]
|
|
out = adj[node_id]
|
|
if i >= len(out):
|
|
state[node_id] = BLACK
|
|
stack.pop()
|
|
continue
|
|
stack[-1] = (node_id, i + 1)
|
|
edge = out[i]
|
|
target = edge.target
|
|
if state[target] == GREY:
|
|
back.add((edge.source, edge.target, edge.sourceHandle or ""))
|
|
elif state[target] == WHITE:
|
|
state[target] = GREY
|
|
stack.append((target, 0))
|
|
return back
|
|
|
|
|
|
def _ranks(
|
|
nodes: dict[str, GraphNode], edges: list, back: set[tuple[str, str, str]],
|
|
) -> dict[str, int]:
|
|
"""最长路径分层。并行分支会落在同一层,串行的才会被推开。"""
|
|
forward = [e for e in edges if (e.source, e.target, e.sourceHandle or "") not in back]
|
|
succ: dict[str, list[str]] = defaultdict(list)
|
|
indeg: dict[str, int] = dict.fromkeys(nodes, 0)
|
|
for e in forward:
|
|
succ[e.source].append(e.target)
|
|
indeg[e.target] += 1
|
|
|
|
rank = dict.fromkeys(nodes, 0)
|
|
queue = deque(sorted(n for n in nodes if indeg[n] == 0))
|
|
while queue:
|
|
node_id = queue.popleft()
|
|
for target in succ[node_id]:
|
|
rank[target] = max(rank[target], rank[node_id] + 1)
|
|
indeg[target] -= 1
|
|
if indeg[target] == 0:
|
|
queue.append(target)
|
|
return rank
|
|
|
|
|
|
def _handle_order(node: GraphNode) -> list[str]:
|
|
"""节点出口在画布上从上到下的顺序。分支的 yes/no 就该是这个顺序。
|
|
|
|
前端 `sourceHandles()` 决定出口怎么画,这里决定它们怎么排。两份必须一致,
|
|
否则分支标签的上下关系会和连线对不上。
|
|
"""
|
|
cfg = node.config
|
|
if node.type == NodeType.BRANCH:
|
|
keys = [
|
|
str(c.get("key"))
|
|
for c in (cfg.get("cases") or [])
|
|
if isinstance(c, dict) and c.get("key")
|
|
]
|
|
return keys + [BRANCH_FALLBACK]
|
|
if node.type == NodeType.LOOP:
|
|
return ["body", "done"]
|
|
if node.type == NodeType.HUMAN and cfg.get("mode", "approve") == "approve":
|
|
return ["approved", "rejected"]
|
|
return ["out"]
|
|
|
|
|
|
def _initial_orders(
|
|
spec: GraphSpec, nodes: dict[str, GraphNode], rank: dict[str, int], edges: list,
|
|
) -> list[list[str]]:
|
|
"""第 0 层先按声明顺序(入口排前面),后面的层按上游位置摆一遍。"""
|
|
max_rank = max(rank.values(), default=0)
|
|
orders: list[list[str]] = [[] for _ in range(max_rank + 1)]
|
|
for node in spec.nodes:
|
|
if node.id in rank:
|
|
orders[rank[node.id]].append(node.id)
|
|
|
|
preds: dict[str, list[str]] = defaultdict(list)
|
|
for e in edges:
|
|
preds[e.target].append(e.source)
|
|
|
|
for r in range(1, max_rank + 1):
|
|
pos = {nid: i for i, nid in enumerate(orders[r - 1])}
|
|
here = {nid: i for i, nid in enumerate(orders[r])}
|
|
def key(nid: str) -> tuple[float, int]:
|
|
ps = [pos[p] for p in preds[nid] if p in pos]
|
|
return (median(ps) if ps else here[nid], here[nid])
|
|
orders[r].sort(key=key)
|
|
return orders
|
|
|
|
|
|
def _crossings(orders: list[list[str]], rank: dict[str, int], edges: list) -> int:
|
|
"""相邻两层之间的连线交叉数。只数跨一层的边——跨多层的长边没有虚拟节点,
|
|
数不准,不如不数,免得把排序带偏。"""
|
|
total = 0
|
|
by_rank: dict[int, list] = defaultdict(list)
|
|
for e in edges:
|
|
rs, rt = rank[e.source], rank[e.target]
|
|
if rt == rs + 1:
|
|
by_rank[rs].append(e)
|
|
for r, es in by_rank.items():
|
|
if r + 1 >= len(orders):
|
|
continue
|
|
pos_s = {nid: i for i, nid in enumerate(orders[r])}
|
|
pos_t = {nid: i for i, nid in enumerate(orders[r + 1])}
|
|
pairs = [(pos_s[e.source], pos_t[e.target]) for e in es]
|
|
for i in range(len(pairs)):
|
|
for j in range(i + 1, len(pairs)):
|
|
a, b = pairs[i], pairs[j]
|
|
if (a[0] - b[0]) * (a[1] - b[1]) < 0:
|
|
total += 1
|
|
return total
|
|
|
|
|
|
def _sweep(
|
|
orders: list[list[str]], neigh: dict[str, list[str]], down: bool,
|
|
) -> list[list[str]]:
|
|
"""一轮中位数扫描。没邻居的节点按当前位置排,不被扫到队尾去。"""
|
|
result = [list(ids) for ids in orders]
|
|
seq = range(1, len(result)) if down else range(len(result) - 2, -1, -1)
|
|
for r in seq:
|
|
ref_rank = r - 1 if down else r + 1
|
|
pos = {nid: i for i, nid in enumerate(result[ref_rank])}
|
|
here = {nid: i for i, nid in enumerate(result[r])}
|
|
|
|
def key(nid: str) -> tuple[float, int]:
|
|
ps = [pos[p] for p in neigh[nid] if p in pos]
|
|
return (median(ps) if ps else here[nid], here[nid])
|
|
|
|
result[r].sort(key=key)
|
|
return result
|
|
|
|
|
|
def _minimize_crossings(
|
|
orders: list[list[str]], rank: dict[str, int], edges: list,
|
|
preds: dict[str, list[str]], succs: dict[str, list[str]],
|
|
) -> list[list[str]]:
|
|
best = [list(ids) for ids in orders]
|
|
best_score = _crossings(best, rank, edges)
|
|
for iteration in range(6):
|
|
orders = _sweep(orders, preds if iteration % 2 == 0 else succs, iteration % 2 == 0)
|
|
score = _crossings(orders, rank, edges)
|
|
if score < best_score:
|
|
best, best_score = [list(ids) for ids in orders], score
|
|
if best_score == 0:
|
|
break
|
|
return best
|
|
|
|
|
|
def _apply_handle_order(
|
|
orders: list[list[str]], spec: GraphSpec, nodes: dict[str, GraphNode],
|
|
rank: dict[str, int],
|
|
) -> None:
|
|
"""分支/循环的出口有语义顺序,交叉再少也得让 yes 排在 no 上面。
|
|
|
|
做法是"占位重排":这些后继本来占着层里的哪几个格子,就把它们按出口顺序
|
|
填回那几个格子——层里其他节点的相对次序不受影响。
|
|
"""
|
|
outgoing: dict[str, list] = defaultdict(list)
|
|
for e in spec.edges:
|
|
outgoing[e.source].append(e)
|
|
|
|
for node in spec.nodes:
|
|
handles = _handle_order(node)
|
|
if len(handles) < 2 or node.id not in nodes:
|
|
continue
|
|
outs = outgoing.get(node.id, [])
|
|
if not outs:
|
|
continue
|
|
groups: list[str] = []
|
|
for handle in handles:
|
|
for e in outs:
|
|
if e.target not in nodes or e.target == node.id:
|
|
continue
|
|
if (e.sourceHandle or handles[0]) == handle and e.target not in groups:
|
|
groups.append(e.target)
|
|
# 同一层里按出口顺序重排;跨层的没法重排,跳过
|
|
by_rank: dict[int, list[str]] = defaultdict(list)
|
|
for target in groups:
|
|
by_rank[rank[target]].append(target)
|
|
for r, targets in by_rank.items():
|
|
if len(targets) < 2 or r >= len(orders):
|
|
continue
|
|
slots = sorted(orders[r].index(t) for t in targets if t in orders[r])
|
|
if len(slots) != len(targets):
|
|
continue
|
|
for slot, target in zip(slots, targets):
|
|
orders[r][slot] = target
|
|
|
|
|
|
def _stack(orders: list[list[str]], nodes: dict[str, GraphNode], row_gap: float) -> dict[str, float]:
|
|
"""每层先均匀堆叠,整层以 y=0 为中心。"""
|
|
y: dict[str, float] = {}
|
|
for ids in orders:
|
|
heights = [_height(nodes[i]) for i in ids]
|
|
total = sum(heights) + row_gap * (len(ids) - 1)
|
|
cursor = -total / 2
|
|
for node_id, h in zip(ids, heights):
|
|
y[node_id] = cursor + h / 2
|
|
cursor += h + row_gap
|
|
return y
|
|
|
|
|
|
def _relax(
|
|
y: dict[str, float], orders: list[list[str]], nodes: dict[str, GraphNode],
|
|
preds: dict[str, list[str]], succs: dict[str, list[str]], row_gap: float,
|
|
rounds: int = 6,
|
|
) -> None:
|
|
"""按邻居重心松弛,再把同层节点推开到互不重叠。
|
|
|
|
推开的正向扫描会把整层往下带,所以每层收尾要整体平移回重心——平移不改变
|
|
层内间距,重叠约束依然成立。
|
|
"""
|
|
for iteration in range(rounds):
|
|
down = iteration % 2 == 0
|
|
neigh = preds if down else succs
|
|
seq = range(len(orders)) if down else range(len(orders) - 1, -1, -1)
|
|
for r in seq:
|
|
ids = orders[r]
|
|
if not ids:
|
|
continue
|
|
desired = []
|
|
for node_id in ids:
|
|
ys = [y[p] for p in neigh[node_id] if p in y]
|
|
desired.append(sum(ys) / len(ys) if ys else y[node_id])
|
|
cursor: float | None = None
|
|
for i, node_id in enumerate(ids):
|
|
if cursor is None:
|
|
cursor = desired[i]
|
|
else:
|
|
gap = _height(nodes[ids[i - 1]]) / 2 + row_gap + _height(nodes[node_id]) / 2
|
|
cursor = max(desired[i], cursor + gap)
|
|
y[node_id] = cursor
|
|
shift = sum(desired) / len(desired) - sum(y[i] for i in ids) / len(ids)
|
|
for node_id in ids:
|
|
y[node_id] += shift
|
|
|
|
|
|
def _lane_count(intervals: list[tuple[float, float]]) -> int:
|
|
"""竖直走线至少要几条车道。
|
|
|
|
就是区间图上的首次适应染色,按起点排序即最优。前端 `routing.ts` 用的是
|
|
同一套贪心,所以两边算出来的车道数一致——否则走廊留窄了,线还是会叠。
|
|
"""
|
|
lanes: list[float] = []
|
|
for start, end in sorted((min(a, b), max(a, b)) for a, b in intervals):
|
|
for i, lane_end in enumerate(lanes):
|
|
# 首尾相接(共用扇出点)不算冲突:那正是一条总线的样子。
|
|
# 真叠在一起(重叠超过 TOLERANCE)才换一条道。
|
|
if start >= lane_end - LANE_OVERLAP_TOLERANCE:
|
|
lanes[i] = max(lane_end, end)
|
|
break
|
|
else:
|
|
lanes.append(end)
|
|
return len(lanes)
|
|
|
|
|
|
def _corridor_width(
|
|
boundary: int, rank: dict[str, int], edges: list, y: dict[str, float],
|
|
) -> float:
|
|
"""第 boundary 层和第 boundary+1 层之间要留多宽。
|
|
|
|
穿过这条走廊的边在竖直方向最多重叠几条,就至少要几条车道。
|
|
"""
|
|
intervals = [
|
|
(y[e.source], y[e.target])
|
|
for e in edges
|
|
if rank[e.source] <= boundary < rank[e.target]
|
|
]
|
|
lanes = _lane_count(intervals)
|
|
if lanes <= 1:
|
|
return CORRIDOR_MIN
|
|
width = STUB * 2 + LANE * (lanes - 1) + 28
|
|
return float(min(CORRIDOR_MAX, max(CORRIDOR_MIN, width)))
|
|
|
|
|
|
def auto_layout(
|
|
spec: GraphSpec, *, x_gap: int | None = None, y_gap: int | None = None,
|
|
) -> GraphSpec:
|
|
"""给整张图重新分配坐标,返回同一个 spec(原地改坐标)。
|
|
|
|
`x_gap` / `y_gap` 是旧接口留下的旋钮,现在只当"走廊最窄多少、同列最少隔
|
|
多少"用:实际间距由拓扑算出来的车道需求决定,不再是固定 300/150。
|
|
"""
|
|
if not spec.nodes:
|
|
return spec
|
|
|
|
nodes = spec.node_map()
|
|
edges = _dedupe_edges(spec, nodes)
|
|
# 自环不参与分层:它不跨层,留着只会让"自己的前驱是自己"这种噪音渗进排序
|
|
edges = [e for e in edges if e.source != e.target]
|
|
|
|
back = _back_edges(nodes, edges)
|
|
rank = _ranks(nodes, edges, back)
|
|
|
|
preds: dict[str, list[str]] = defaultdict(list)
|
|
succs: dict[str, list[str]] = defaultdict(list)
|
|
for e in edges:
|
|
preds[e.target].append(e.source)
|
|
succs[e.source].append(e.target)
|
|
|
|
orders = _initial_orders(spec, nodes, rank, edges)
|
|
orders = _minimize_crossings(orders, rank, edges, preds, succs)
|
|
_apply_handle_order(orders, spec, nodes, rank)
|
|
|
|
row_gap = float(y_gap) if y_gap else MIN_ROW_GAP
|
|
corridor_min = max(CORRIDOR_MIN, float(x_gap) - NODE_W) if x_gap else CORRIDOR_MIN
|
|
|
|
y = _stack(orders, nodes, row_gap)
|
|
_relax(y, orders, nodes, preds, succs, row_gap)
|
|
|
|
# 每个节点都有层,也就都在这两个表里;.get 的兜底是防"层算漏了"这种
|
|
# 不该发生的情况——真漏了也把节点摆在图末尾,而不是让它掉在原点压住别人
|
|
x: dict[str, float] = {}
|
|
cursor = float(X_ORIGIN)
|
|
for r, ids in enumerate(orders):
|
|
for node_id in ids:
|
|
x[node_id] = cursor
|
|
if r < len(orders) - 1:
|
|
width = _corridor_width(r, rank, edges, y)
|
|
cursor += NODE_W + max(width, corridor_min)
|
|
|
|
for node in spec.nodes:
|
|
node.position.x = round(x.get(node.id, cursor), 1)
|
|
node.position.y = round(y.get(node.id, 0.0), 1)
|
|
return spec
|