From 8167cadbdf5231da902f0218f12a92d0ba0bd485 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 1 Oct 2026 11:12:27 +0200 Subject: [PATCH] feat: pipeline graph edges merge by shared end, enter nodes by the side, keep fewest crossings Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/AssetGraph/AssetGraphCanvas.svelte | 59 +- .../assets/AssetGraph/AssetGraphEdge.svelte | 83 ++- .../AssetGraph/assetGraphEdgeRouting.test.ts | 197 +++-- .../AssetGraph/assetGraphEdgeRouting.ts | 687 +++++++++++++----- .../assets/AssetGraph/assetGraphLayout.ts | 7 +- 5 files changed, 759 insertions(+), 274 deletions(-) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 7d75809ba2..31fd10030a 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -25,11 +25,12 @@ import { LAYER_GAP, layoutAssetGraph, + layoutRowHeight, PIPELINE_NODE_EXTRA_ROW, PIPELINE_NODE_HEADER, PIPELINE_NODE_HEADER_CARD_PAD } from './assetGraphLayout' - import { routeAssetGraphEdges } from './assetGraphEdgeRouting' + import { detourLane, routeAssetGraphEdges } from './assetGraphEdgeRouting' import { assetsOnlyView, upstreamDeletion, @@ -1679,15 +1680,10 @@ : 1 ) }) - let layoutPositions = $derived( - layoutAssetGraph( - layoutInput, - ADD_NODE_ID, - assetsOnly - ? PIPELINE_NODE_EXTRA_ROW + PIPELINE_NODE_HEADER + PIPELINE_NODE_HEADER_CARD_PAD - : 0 - ) + const extraRowHeight = $derived( + assetsOnly ? PIPELINE_NODE_EXTRA_ROW + PIPELINE_NODE_HEADER + PIPELINE_NODE_HEADER_CARD_PAD : 0 ) + let layoutPositions = $derived(layoutAssetGraph(layoutInput, ADD_NODE_ID, extraRowHeight)) /** Assets-only asset cards size to their text; every other node is `NODE.width`. */ const nodeWidth = (n: { data?: any }): number => n.data?.width ?? NODE.width @@ -1863,44 +1859,37 @@ halfW: nodeWidth(n) / 2 })) ) - // Detour lane for an edge whose run would pass over an unrelated node. An - // edge spanning rows bends inside the first gap below its source and then - // runs straight down the *target's* column (see AssetGraphEdge), so every - // node strictly between the endpoints' rows is tested against the target's - // x, not against a source→target line. A crossed node is routed around on - // the side the target lies. Returns the outermost lane x clearing every - // crossed node, or undefined when the corridor is clear. O(nodes) per edge. + let routeNodes = $derived( + nodeCenters.map((n) => ({ + id: n.id, + cx: n.cx, + top: n.cy, + halfW: n.halfW, + height: layoutRowHeight(extraRowHeight) + })) + ) + // An edge spanning rows goes down its target's column; when a node in the + // rows between is in the way, it takes a lane clear of all of them. const ROUTE_PAD = NODE.gap.horizontal / 2 function detourForEdge(sourceId: string, targetId: string): number | undefined { - const s = nodeCenters.find((n) => n.id === sourceId) - const t = nodeCenters.find((n) => n.id === targetId) - if (!s || !t || s.cy === t.cy) return undefined - const dyTot = t.cy - s.cy - let lane: number | undefined - for (const n of nodeCenters) { - if (n.id === sourceId || n.id === targetId) continue - // strictly between the two rows - if ((n.cy - s.cy) / dyTot <= 0.01 || (n.cy - s.cy) / dyTot >= 0.99) continue - if (Math.abs(t.cx - n.cx) >= n.halfW + 8) continue - // Crossed: a lane just outside this node, toward the target side. - const side = t.cx >= n.cx ? 1 : -1 - const candidate = n.cx + side * (n.halfW + ROUTE_PAD) - // Keep the outermost lane so one detour clears every obstacle. - if (lane == undefined || Math.abs(candidate - s.cx) > Math.abs(lane - s.cx)) lane = candidate - } - return lane + const s = routeNodes.find((n) => n.id === sourceId) + const t = routeNodes.find((n) => n.id === targetId) + return s && t ? detourLane(s, t, routeNodes, ROUTE_PAD) : undefined } let edgeRoutes = $derived( routeAssetGraphEdges( - nodeCenters.map((n) => ({ id: n.id, cx: n.cx, top: n.cy, halfW: n.halfW })), + routeNodes, view.edges .filter((e) => e.kind !== 'add-anchor') .map((e) => ({ id: e.id, source: e.source, target: e.target, - laneX: detourForEdge(e.source, e.target) + laneX: detourForEdge(e.source, e.target), + // What sets how an edge is drawn (see the styling below): only + // edges that look the same may share a segment. + style: `${e.kind}:${e.reactive ? 1 : 0}:${e.missing ? 1 : 0}` })), LAYER_GAP ) diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte index e28add8f74..be27df3ba0 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte @@ -1,5 +1,5 @@