diff --git a/web/src/components/WorkGraphCanvas.vue b/web/src/components/WorkGraphCanvas.vue
index 9daea21..62d277e 100644
--- a/web/src/components/WorkGraphCanvas.vue
+++ b/web/src/components/WorkGraphCanvas.vue
@@ -214,10 +214,12 @@ const waves = computed(() => {
for (const id of wave) remaining.delete(id);
out.push(wave);
}
- if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一列,别让它们消失
+ if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一行,别让它们消失
return out;
});
+// 布局方向:wave(依赖深度)走 y 轴自上而下,同波内的并行节点走 x 轴左右排开——
+// 节点一多,从左到右排会把屏幕撑成很宽的 4 栏,改成自上而下更贴合竖直滚动的画布容器。
const laidOut = computed(() => {
const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n]));
const out = [];
@@ -231,8 +233,8 @@ const laidOut = computed(() => {
status: n.status || 'pending',
nodeKind: n.nodeKind,
attempt: n.attempt || 0,
- x: PAD + wi * (NW + GAP_X),
- y: PAD + ni * (NH + GAP_Y),
+ x: PAD + ni * (NW + GAP_X),
+ y: PAD + wi * (NH + GAP_Y),
});
});
});
@@ -255,16 +257,16 @@ const forwardEdges = computed(() =>
+ (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '')
// 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局
+ (c ? `|数据契约 ${c.title}(? = 可选)` : ''),
- x1: a.x + NW, y1: a.y + NH / 2,
- x2: b.x, y2: b.y + NH / 2,
+ x1: a.x + NW / 2, y1: a.y + NH,
+ x2: b.x + NW / 2, y2: b.y,
contract: c,
}];
}),
);
/**
- * 契约药丸的位置 = 边的中点(两列之间的空白带),宽度按字数估。
- * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):GAP_X 只有 52px,
+ * 契约药丸的位置 = 边的中点(两行之间的空白带),宽度按字数估。
+ * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):药丸宽度封顶到节点列宽,
* 不截会盖到相邻节点上——完整名字在
里。
*/
const contractPills = computed(() =>
@@ -278,7 +280,8 @@ const contractPills = computed(() =>
key: e.key,
x: (e.x1 + e.x2) / 2,
y: (e.y1 + e.y2) / 2,
- w: Math.min(GAP_X + 10, text.length * 5.9 + 12),
+ // 边现在竖着走(同列上下相邻),药丸宽度受节点列宽约束,不再是 GAP_X
+ w: Math.min(NW - 16, text.length * 5.9 + 12),
text,
title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`,
optionalOnly: e.contract.optionalOnly,
@@ -290,12 +293,12 @@ const contractPills = computed(() =>
* backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。
*
* 重叠问题拆成两半,各用一招解决,不引图形库:
- * ① 与节点重叠 —— 回退边一律走**节点带下方**的空白区,起终点都取节点底边中点,
- * 三次贝塞尔两个控制点都压在同一条泳道高度上 → 曲线永远不穿过任何节点矩形;
- * ② 与其它回退边重叠 —— 按 x 区间做贪心分道(区间重叠的边必然分到不同泳道),
- * 泳道纵向拉开 LANE_H,画布高度随泳道数增长。区间不重叠的边共用一道,不浪费高度。
- * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变高;
- * 升级路径是改成上下双侧分流(奇偶道走节点带上方),本版不做。
+ * ① 与节点重叠 —— 回退边一律走**节点带右侧**的空白区,起终点都取节点右边中点,
+ * 三次贝塞尔两个控制点都压在同一条泳道横坐标上 → 曲线永远不穿过任何节点矩形;
+ * ② 与其它回退边重叠 —— 按 y 区间做贪心分道(区间重叠的边必然分到不同泳道),
+ * 泳道横向拉开 LANE_H,画布宽度随泳道数增长。区间不重叠的边共用一道,不浪费宽度。
+ * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变宽;
+ * 升级路径是改成左右双侧分流(奇偶道走节点带左侧),本版不做。
*/
const backwardEdges = computed(() => {
const raw = [];
@@ -304,13 +307,13 @@ const backwardEdges = computed(() => {
const a = posById.value.get(String(e.from));
const b = posById.value.get(String(e.to));
if (!a || !b) return;
- const sx = a.x + NW / 2, sy = a.y + NH;
- const tx = b.x + NW / 2, ty = b.y + NH;
+ const sx = a.x + NW, sy = a.y + NH / 2;
+ const tx = b.x + NW, ty = b.y + NH / 2;
raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty,
- lo: Math.min(sx, tx), hi: Math.max(sx, tx) });
+ lo: Math.min(sy, ty), hi: Math.max(sy, ty) });
});
- // 贪心分道:按区间左端排序,塞进第一条不与已占区间重叠的泳道
+ // 贪心分道:按区间上端排序,塞进第一条不与已占区间重叠的泳道
const lanes = [];
raw.sort((p, q) => p.lo - q.lo);
for (const e of raw) {
@@ -321,13 +324,13 @@ const backwardEdges = computed(() => {
}
return raw.map((e) => {
- const laneY = nodeBandH.value + PAD + (e.lane + 1) * LANE_H;
+ const laneX = nodeBandW.value + PAD + (e.lane + 1) * LANE_H;
return {
...e,
- d: `M${e.sx},${e.sy} C${e.sx},${laneY} ${e.tx},${laneY} ${e.tx},${e.ty}`,
- // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 y,纵向化简为 (sy+ty+6*laneY)/8)
- mx: (e.sx + e.tx) / 2,
- my: (e.sy + e.ty + 6 * laneY) / 8,
+ d: `M${e.sx},${e.sy} C${laneX},${e.sy} ${laneX},${e.ty} ${e.tx},${e.ty}`,
+ // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 x,横向化简为 (sx+tx+6*laneX)/8)
+ mx: (e.sx + e.tx + 6 * laneX) / 8,
+ my: (e.sy + e.ty) / 2,
loopCount: latestLoopCount(e.edgeId),
};
});
@@ -350,21 +353,21 @@ const anomalyNodes = computed(() => laidOut.value.filter((n) => n.status === 'bl
const anomalyBand = computed(() => {
if (!backwardEdges.value.length) return null;
return {
- x: PAD / 2,
- y: nodeBandH.value + PAD / 2,
- w: W.value - PAD,
- h: laneCount.value * LANE_H + LANE_H / 2,
+ x: nodeBandW.value + PAD / 2,
+ y: PAD / 2,
+ w: laneCount.value * LANE_H + LANE_H / 2,
+ h: H.value - PAD,
};
});
-const nodeBandH = computed(() => {
+const nodeBandW = computed(() => {
const maxRow = Math.max(...waves.value.map((w) => w.length), 1);
- return PAD + maxRow * (NH + GAP_Y) - GAP_Y;
+ return PAD + maxRow * (NW + GAP_X) - GAP_X;
});
-const W = computed(() =>
- Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NW + GAP_X) - GAP_X));
-const H = computed(() => nodeBandH.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
+const W = computed(() => nodeBandW.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
+const H = computed(() =>
+ Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NH + GAP_Y) - GAP_Y));
function trim(s) {
const str = String(s ?? '');