Commit 5f2560da75516afe43bd4277d3556a594cc4fe8c
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
Showing
1 changed file
with
35 additions
and
32 deletions
web/src/components/WorkGraphCanvas.vue
| ... | ... | @@ -214,10 +214,12 @@ const waves = computed(() => { |
| 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(() => { |
| 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(() => |
| 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(() => |
| 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(() => |
| 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(() => { |
| 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(() => { |
| 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(() => laidOut.value.filter((n) => n.status === '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 ?? ''); | ... | ... |