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 214 for (const id of wave) remaining.delete(id);
215 215 out.push(wave);
216 216 }
217   - if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一列,别让它们消失
  217 + if (remaining.size) out.push([...remaining]); // 兜底:环里的节点单独摆一行,别让它们消失
218 218 return out;
219 219 });
220 220  
  221 +// 布局方向:wave(依赖深度)走 y 轴自上而下,同波内的并行节点走 x 轴左右排开——
  222 +// 节点一多,从左到右排会把屏幕撑成很宽的 4 栏,改成自上而下更贴合竖直滚动的画布容器。
221 223 const laidOut = computed(() => {
222 224 const byId = new Map(props.nodes.map((n) => [String(n.defNodeId), n]));
223 225 const out = [];
... ... @@ -231,8 +233,8 @@ const laidOut = computed(() =&gt; {
231 233 status: n.status || 'pending',
232 234 nodeKind: n.nodeKind,
233 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 257 + (cond === 'fail' ? `|fail_reason=${e.failReason || '(兜底边:任何未匹配的失败)'}` : '')
256 258 // 这条边传的是什么(graph_edge_contracts)。悬停在线上就能看全,不占布局
257 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 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 270 * 不截会盖到相邻节点上——完整名字在 <title> 里。
269 271 */
270 272 const contractPills = computed(() =>
... ... @@ -278,7 +280,8 @@ const contractPills = computed(() =&gt;
278 280 key: e.key,
279 281 x: (e.x1 + e.x2) / 2,
280 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 285 text,
283 286 title: `数据契约:${e.contract.title}(? = 可选输入,缺了不算错)`,
284 287 optionalOnly: e.contract.optionalOnly,
... ... @@ -290,12 +293,12 @@ const contractPills = computed(() =&gt;
290 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 303 const backwardEdges = computed(() => {
301 304 const raw = [];
... ... @@ -304,13 +307,13 @@ const backwardEdges = computed(() =&gt; {
304 307 const a = posById.value.get(String(e.from));
305 308 const b = posById.value.get(String(e.to));
306 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 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 317 const lanes = [];
315 318 raw.sort((p, q) => p.lo - q.lo);
316 319 for (const e of raw) {
... ... @@ -321,13 +324,13 @@ const backwardEdges = computed(() =&gt; {
321 324 }
322 325  
323 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 328 return {
326 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 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 353 const anomalyBand = computed(() => {
351 354 if (!backwardEdges.value.length) return null;
352 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 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 372 function trim(s) {
370 373 const str = String(s ?? '');
... ...