Commit 5f2560da75516afe43bd4277d3556a594cc4fe8c

Authored by yibo
1 parent 4253c4be

fix(graph-console): org/work graph 节点布局改为从上到下

节点多时左右排列会把屏幕撑成很宽的多栏视图,改成按依赖深度自上而下、
同波并行节点左右排开,更贴合画布容器的竖直滚动。backward 回退边路由
相应改到节点带右侧走车道(原来在下方)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7xYyvv3pzuV9diMVUH5QL
web/src/components/WorkGraphCanvas.vue
@@ -214,10 +214,12 @@ const waves = computed(() =&gt; { @@ -214,10 +214,12 @@ const waves = computed(() =&gt; {
214 for (const id of wave) remaining.delete(id); 214 for (const id of wave) remaining.delete(id);
215 out.push(wave); 215 out.push(wave);
216 } 216 }
217 - if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一列,别让它们消失 217 + if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一行,别让它们消失
218 return out; 218 return out;
219 }); 219 });
220 220
  221 +// 布局方向:wave(依赖深度)走 y 轴自上而下,同波内的并行节点走 x 轴左右排开——
  222 +// 节点一多,从左到右排会把屏幕撑成很宽的 4 栏,改成自上而下更贴合竖直滚动的画布容器。
221 const laidOut = computed(() => { 223 const laidOut = computed(() => {
222 const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n])); 224 const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n]));
223 const out = []; 225 const out = [];
@@ -231,8 +233,8 @@ const laidOut = computed(() =&gt; { @@ -231,8 +233,8 @@ const laidOut = computed(() =&gt; {
231 status: n.status || 'pending', 233 status: n.status || 'pending',
232 nodeKind: n.nodeKind, 234 nodeKind: n.nodeKind,
233 attempt: n.attempt || 0, 235 attempt: n.attempt || 0,
234 - x: PAD + wi * (NW + GAP_X),  
235 - y: PAD + ni * (NH + GAP_Y), 236 + x: PAD + ni * (NW + GAP_X),
  237 + y: PAD + wi * (NH + GAP_Y),
236 }); 238 });
237 }); 239 });
238 }); 240 });
@@ -255,16 +257,16 @@ const forwardEdges = computed(() =&gt; @@ -255,16 +257,16 @@ const forwardEdges = computed(() =&gt;
255 + (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '') 257 + (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '')
256 // 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局 258 // 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局
257 + (c ? `|数据契约 ${c.title}(? = 可选)` : ''), 259 + (c ? `|数据契约 ${c.title}(? = 可选)` : ''),
258 - x1: a.x + NW, y1: a.y + NH / 2,  
259 - x2: b.x, y2: b.y + NH / 2, 260 + x1: a.x + NW / 2, y1: a.y + NH,
  261 + x2: b.x + NW / 2, y2: b.y,
260 contract: c, 262 contract: c,
261 }]; 263 }];
262 }), 264 }),
263 ); 265 );
264 266
265 /** 267 /**
266 - * 契约药丸的位置 = 边的中点(两列之间的空白带),宽度按字数估。  
267 - * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):GAP_X 只有 52px, 268 + * 契约药丸的位置 = 边的中点(两行之间的空白带),宽度按字数估。
  269 + * kind 名最长按 7 字符截断(`acceptance` → `accepta…`):药丸宽度封顶到节点列宽,
268 * 不截会盖到相邻节点上——完整名字在 <title> 里。 270 * 不截会盖到相邻节点上——完整名字在 <title> 里。
269 */ 271 */
270 const contractPills = computed(() => 272 const contractPills = computed(() =>
@@ -278,7 +280,8 @@ const contractPills = computed(() =&gt; @@ -278,7 +280,8 @@ const contractPills = computed(() =&gt;
278 key: e.key, 280 key: e.key,
279 x: (e.x1 + e.x2) / 2, 281 x: (e.x1 + e.x2) / 2,
280 y: (e.y1 + e.y2) / 2, 282 y: (e.y1 + e.y2) / 2,
281 - w: Math.min(GAP_X + 10, text.length * 5.9 + 12), 283 + // 边现在竖着走(同列上下相邻),药丸宽度受节点列宽约束,不再是 GAP_X
  284 + w: Math.min(NW - 16, text.length * 5.9 + 12),
282 text, 285 text,
283 title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`, 286 title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`,
284 optionalOnly: e.contract.optionalOnly, 287 optionalOnly: e.contract.optionalOnly,
@@ -290,12 +293,12 @@ const contractPills = computed(() =&gt; @@ -290,12 +293,12 @@ const contractPills = computed(() =&gt;
290 * backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。 293 * backward 边的曲线路由(§八.5 点名的「真实手写前端工程量」)。
291 * 294 *
292 * 重叠问题拆成两半,各用一招解决,不引图形库: 295 * 重叠问题拆成两半,各用一招解决,不引图形库:
293 - * ① 与节点重叠 —— 回退边一律走**节点带下方**的空白区,起终点都取节点底边中点,  
294 - * 三次贝塞尔两个控制点都压在同一条泳道高度上 → 曲线永远不穿过任何节点矩形;  
295 - * ② 与其它回退边重叠 —— 按 x 区间做贪心分道(区间重叠的边必然分到不同泳道),  
296 - * 泳道纵向拉开 LANE_H,画布高度随泳道数增长。区间不重叠的边共用一道,不浪费高度。  
297 - * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变高;  
298 - * 升级路径是改成上下双侧分流(奇偶道走节点带上方),本版不做。 296 + * ① 与节点重叠 —— 回退边一律走**节点带右侧**的空白区,起终点都取节点右边中点,
  297 + * 三次贝塞尔两个控制点都压在同一条泳道横坐标上 → 曲线永远不穿过任何节点矩形;
  298 + * ② 与其它回退边重叠 —— 按 y 区间做贪心分道(区间重叠的边必然分到不同泳道),
  299 + * 泳道横向拉开 LANE_H,画布宽度随泳道数增长。区间不重叠的边共用一道,不浪费宽度。
  300 + * 代价:泳道数 = 最大同时重叠的回退边数,多到十几条时画布会明显变宽;
  301 + * 升级路径是改成左右双侧分流(奇偶道走节点带左侧),本版不做。
299 */ 302 */
300 const backwardEdges = computed(() => { 303 const backwardEdges = computed(() => {
301 const raw = []; 304 const raw = [];
@@ -304,13 +307,13 @@ const backwardEdges = computed(() =&gt; { @@ -304,13 +307,13 @@ const backwardEdges = computed(() =&gt; {
304 const a = posById.value.get(String(e.from)); 307 const a = posById.value.get(String(e.from));
305 const b = posById.value.get(String(e.to)); 308 const b = posById.value.get(String(e.to));
306 if (!a || !b) return; 309 if (!a || !b) return;
307 - const sx = a.x + NW / 2, sy = a.y + NH;  
308 - const tx = b.x + NW / 2, ty = b.y + NH; 310 + const sx = a.x + NW, sy = a.y + NH / 2;
  311 + const tx = b.x + NW, ty = b.y + NH / 2;
309 raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty, 312 raw.push({ key: e.id || `${e.from}-${e.to}-${i}`, edgeId: e.id, sx, sy, tx, ty,
310 - lo: Math.min(sx, tx), hi: Math.max(sx, tx) }); 313 + lo: Math.min(sy, ty), hi: Math.max(sy, ty) });
311 }); 314 });
312 315
313 - // 贪心分道:按区间左端排序,塞进第一条不与已占区间重叠的泳道 316 + // 贪心分道:按区间上端排序,塞进第一条不与已占区间重叠的泳道
314 const lanes = []; 317 const lanes = [];
315 raw.sort((p, q) => p.lo - q.lo); 318 raw.sort((p, q) => p.lo - q.lo);
316 for (const e of raw) { 319 for (const e of raw) {
@@ -321,13 +324,13 @@ const backwardEdges = computed(() =&gt; { @@ -321,13 +324,13 @@ const backwardEdges = computed(() =&gt; {
321 } 324 }
322 325
323 return raw.map((e) => { 326 return raw.map((e) => {
324 - const laneY = nodeBandH.value + PAD + (e.lane + 1) * LANE_H; 327 + const laneX = nodeBandW.value + PAD + (e.lane + 1) * LANE_H;
325 return { 328 return {
326 ...e, 329 ...e,
327 - d: `M${e.sx},${e.sy} C${e.sx},${laneY} ${e.tx},${laneY} ${e.tx},${e.ty}`,  
328 - // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 y,纵向化简为 (sy+ty+6*laneY)/8)  
329 - mx: (e.sx + e.tx) / 2,  
330 - my: (e.sy + e.ty + 6 * laneY) / 8, 330 + d: `M${e.sx},${e.sy} C${laneX},${e.sy} ${laneX},${e.ty} ${e.tx},${e.ty}`,
  331 + // 三次贝塞尔 t=0.5 处的坐标(两个控制点同 x,横向化简为 (sx+tx+6*laneX)/8)
  332 + mx: (e.sx + e.tx + 6 * laneX) / 8,
  333 + my: (e.sy + e.ty) / 2,
331 loopCount: latestLoopCount(e.edgeId), 334 loopCount: latestLoopCount(e.edgeId),
332 }; 335 };
333 }); 336 });
@@ -350,21 +353,21 @@ const anomalyNodes = computed(() =&gt; laidOut.value.filter((n) =&gt; n.status === &#39;bl @@ -350,21 +353,21 @@ const anomalyNodes = computed(() =&gt; laidOut.value.filter((n) =&gt; n.status === &#39;bl
350 const anomalyBand = computed(() => { 353 const anomalyBand = computed(() => {
351 if (!backwardEdges.value.length) return null; 354 if (!backwardEdges.value.length) return null;
352 return { 355 return {
353 - x: PAD / 2,  
354 - y: nodeBandH.value + PAD / 2,  
355 - w: W.value - PAD,  
356 - h: laneCount.value * LANE_H + LANE_H / 2, 356 + x: nodeBandW.value + PAD / 2,
  357 + y: PAD / 2,
  358 + w: laneCount.value * LANE_H + LANE_H / 2,
  359 + h: H.value - PAD,
357 }; 360 };
358 }); 361 });
359 362
360 -const nodeBandH = computed(() => { 363 +const nodeBandW = computed(() => {
361 const maxRow = Math.max(...waves.value.map((w) => w.length), 1); 364 const maxRow = Math.max(...waves.value.map((w) => w.length), 1);
362 - return PAD + maxRow * (NH + GAP_Y) - GAP_Y; 365 + return PAD + maxRow * (NW + GAP_X) - GAP_X;
363 }); 366 });
364 367
365 -const W = computed(() =>  
366 - Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NW + GAP_X) - GAP_X));  
367 -const H = computed(() => nodeBandH.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0)); 368 +const W = computed(() => nodeBandW.value + PAD + laneCount.value * LANE_H + (laneCount.value ? LANE_H : 0));
  369 +const H = computed(() =>
  370 + Math.max(320, PAD * 2 + Math.max(waves.value.length, 1) * (NH + GAP_Y) - GAP_Y));
368 371
369 function trim(s) { 372 function trim(s) {
370 const str = String(s ?? ''); 373 const str = String(s ?? '');