From 5f2560da75516afe43bd4277d3556a594cc4fe8c Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 30 Aug 2026 23:29:44 +0800 Subject: [PATCH] fix(graph-console): org/work graph 节点布局改为从上到下 --- web/src/components/WorkGraphCanvas.vue | 67 +++++++++++++++++++++++++++++++++++-------------------------------- 1 file changed, 35 insertions(+), 32 deletions(-) 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 ?? ''); -- libgit2 0.21.4