diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte
index d9bfbb52c4..7d75809ba2 100644
--- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte
+++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte
@@ -23,11 +23,13 @@
import PanToNode from './PanToNode.svelte'
import InitialFitView from './InitialFitView.svelte'
import {
+ LAYER_GAP,
layoutAssetGraph,
PIPELINE_NODE_EXTRA_ROW,
PIPELINE_NODE_HEADER,
PIPELINE_NODE_HEADER_CARD_PAD
} from './assetGraphLayout'
+ import { routeAssetGraphEdges } from './assetGraphEdgeRouting'
import {
assetsOnlyView,
upstreamDeletion,
@@ -1889,6 +1891,21 @@
return lane
}
+ let edgeRoutes = $derived(
+ routeAssetGraphEdges(
+ nodeCenters.map((n) => ({ id: n.id, cx: n.cx, top: n.cy, halfW: n.halfW })),
+ 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)
+ })),
+ LAYER_GAP
+ )
+ )
+
let flowEdges = $derived.by(() =>
view.edges
// Anchor edges are layout-only.
@@ -2035,7 +2052,7 @@
target: e.target,
type: 'asset',
data: {
- detourX: detourForEdge(e.source, e.target),
+ route: edgeRoutes.get(e.id),
// Data-test badge on the producer→asset write-edge. The
// producer's last-run status (the script fails if any test
// fails) tints it green/red; neutral until it has run.
diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte
index 90825986b8..e28add8f74 100644
--- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte
+++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte
@@ -1,10 +1,11 @@
diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphEdgeRouting.test.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphEdgeRouting.test.ts
new file mode 100644
index 0000000000..a25831ebe6
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphEdgeRouting.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest'
+import { routeAssetGraphEdges, roundedPath, type RouteNode } from './assetGraphEdgeRouting'
+
+const GAP = 80
+const node = (id: string, cx: number, top: number): RouteNode => ({ id, cx, top, halfW: 100 })
+
+describe('routeAssetGraphEdges', () => {
+ // Four sources in one row feeding one target below: the screenshot shape
+ // where every edge used to land on the same point and share one height.
+ const sources = [node('a', 0, 0), node('b', 300, 0), node('c', 600, 0), node('d', 900, 0)]
+ const target = node('t', 450, 200)
+ const fanIn = ['a', 'b', 'c', 'd'].map((s) => ({ id: `${s}-t`, source: s, target: 't' }))
+ const routes = routeAssetGraphEdges([...sources, target], fanIn, GAP)
+
+ it('spreads a fan-in across the target, in the order of the sources', () => {
+ const offsets = fanIn.map((e) => routes.get(e.id)!.targetOffset)
+ expect(new Set(offsets).size).toBe(4)
+ expect([...offsets].sort((x, y) => x - y)).toEqual(offsets)
+ })
+
+ it('keeps every run of a fan-in inside the gap above the target', () => {
+ for (const e of fanIn) {
+ const y = routes.get(e.id)!.y
+ expect(y).toBeGreaterThan(200 - GAP)
+ expect(y).toBeLessThan(200)
+ }
+ })
+
+ it('stacks overlapping runs so none crosses another edge', () => {
+ // Coming from the left, the farther source runs lower: its run then passes
+ // under where the nearer source comes down, instead of through it.
+ expect(routes.get('a-t')!.y).toBeGreaterThan(routes.get('b-t')!.y)
+ // Mirrored on the right.
+ expect(routes.get('d-t')!.y).toBeGreaterThan(routes.get('c-t')!.y)
+ })
+
+ it('offsets edges that share a lane', () => {
+ const nodes = [node('s1', 0, 0), node('s2', 0, 0), node('mid', 0, 200), node('t', 0, 400)]
+ const laned = routeAssetGraphEdges(
+ nodes,
+ [
+ { id: 'e1', source: 's1', target: 't', laneX: 150 },
+ { id: 'e2', source: 's2', target: 't', laneX: 150 }
+ ],
+ GAP
+ )
+ expect(laned.get('e1')!.lane!.x).not.toBe(laned.get('e2')!.lane!.x)
+ })
+
+ it('gives edges joining the same two nodes one route', () => {
+ const r = routeAssetGraphEdges(
+ [node('s', 0, 0), node('t', 0, 200), node('u', 300, 200)],
+ [
+ { id: 'read', source: 's', target: 't' },
+ { id: 'trigger', source: 's', target: 't' },
+ { id: 'other', source: 's', target: 'u' }
+ ],
+ GAP
+ )
+ expect(r.get('trigger')).toEqual(r.get('read'))
+ expect(r.get('other')!.sourceOffset).not.toBe(r.get('read')!.sourceOffset)
+ })
+
+ it('leaves an upward edge unrouted', () => {
+ const r = routeAssetGraphEdges(
+ [node('up', 0, 0), node('down', 0, 200)],
+ [{ id: 'back', source: 'down', target: 'up' }],
+ GAP
+ )
+ expect(r.has('back')).toBe(false)
+ })
+})
+
+describe('roundedPath', () => {
+ it('rounds each corner and ends on the last point', () => {
+ const d = roundedPath(
+ [
+ [0, 0],
+ [0, 50],
+ [100, 50],
+ [100, 100]
+ ],
+ 10
+ )
+ expect(d.startsWith('M 0 0')).toBe(true)
+ expect(d.match(/Q/g)).toHaveLength(2)
+ expect(d.endsWith('L 100 100')).toBe(true)
+ })
+})
diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphEdgeRouting.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphEdgeRouting.ts
new file mode 100644
index 0000000000..9011a2e260
--- /dev/null
+++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphEdgeRouting.ts
@@ -0,0 +1,279 @@
+// Orthogonal edge routing for the asset graph. Every edge is drawn the same way
+// — vertical runs joined by horizontal runs with rounded corners — and three
+// things keep edges apart where they would otherwise be drawn on top of each
+// other:
+//
+// - Ports: the edges leaving (or entering) one node are spread along its bottom
+// (top) edge, ordered by where their other end lies, instead of all meeting
+// at the node's center.
+// - Tracks: the horizontal runs sharing the gap between two layers get distinct
+// heights. Their order follows the channel-routing constraint that removes
+// avoidable crossings: a run must sit above any run that drops out of the gap
+// at an x it spans, and below any run that comes down into the gap at an x it
+// spans.
+// - Lanes: edges that skip layers come down a lane beside the nodes in between
+// (chosen by the caller); edges sharing a lane are offset side by side.
+//
+// Pure: the canvas feeds node boxes and edges, the edge component draws
+// `roundedPath` through the points it gets back.
+
+export type RouteNode = {
+ id: string
+ /** Horizontal center. */
+ cx: number
+ /** Top edge — nodes of one layer share it. */
+ top: number
+ halfW: number
+}
+
+export type RouteEdge = {
+ id: string
+ source: string
+ target: string
+ /** Lane x for an edge that must go around the nodes between its endpoints. */
+ laneX?: number
+}
+
+/** Offsets and heights the edge component combines with its handle coordinates. */
+export type EdgeRoute = {
+ sourceOffset: number
+ targetOffset: number
+ /** Height of the edge's run into the target's column (absolute): in the gap
+ * below the source, or for a laned edge in the gap above the target. */
+ y: number
+ /** For a laned edge: the lane, and the height of the run in the gap below the source. */
+ lane?: { x: number; y: number }
+}
+
+const PORT_GAP = 14
+const LANE_GAP = 10
+const MAX_TRACK_GAP = 10
+// Clearance kept between a gap's runs and the node rows bounding it.
+const GAP_MARGIN_TOP = 18
+const GAP_MARGIN_BOTTOM = 14
+
+type Segment = {
+ edgeId: string
+ gap: number
+ from: number
+ to: number
+ /** x where the run comes down into the gap, and where it leaves it downward. */
+ topPin: number
+ bottomPin: number
+ which: 'upper' | 'lower'
+}
+
+export function routeAssetGraphEdges(
+ nodes: RouteNode[],
+ edges: RouteEdge[],
+ layerGap: number
+): Map {
+ const byId = new Map(nodes.map((n) => [n.id, n]))
+ const layerTops = [...new Set(nodes.map((n) => n.top))].sort((a, b) => a - b)
+ const layerOf = (n: RouteNode) => layerTops.indexOf(n.top)
+ // Edges joining the same two nodes (a read and the trigger it derives) share
+ // one route and are drawn over each other; one of them stands for the pair.
+ const pairKey = (e: RouteEdge) => `${e.source}\u0000${e.target}`
+ const representative = new Map()
+ for (const e of edges) {
+ const s = byId.get(e.source)
+ const t = byId.get(e.target)
+ if (s && t && t.top > s.top && !representative.has(pairKey(e))) representative.set(pairKey(e), e)
+ }
+ const routable = [...representative.values()]
+
+ // Lanes: edges whose lanes land within a few pixels of each other share a
+ // corridor; spread them side by side, ordered by their target's x so they
+ // leave the corridor without crossing.
+ const laneX = new Map()
+ const laned = routable.filter((e) => e.laneX != undefined).sort((a, b) => a.laneX! - b.laneX!)
+ for (let i = 0; i < laned.length; ) {
+ let j = i + 1
+ while (j < laned.length && laned[j].laneX! - laned[i].laneX! < LANE_GAP) j++
+ const group = laned.slice(i, j).sort((a, b) => byId.get(a.target)!.cx - byId.get(b.target)!.cx)
+ group.forEach((e, k) => laneX.set(e.id, e.laneX! + (k - (group.length - 1) / 2) * LANE_GAP))
+ i = j
+ }
+
+ // Ports: where each edge's other end lies, seen from this node.
+ const farX = (e: RouteEdge, end: 'source' | 'target') =>
+ laneX.get(e.id) ?? byId.get(end === 'source' ? e.target : e.source)!.cx
+ const sourceOffset = new Map()
+ const targetOffset = new Map()
+ function spread(node: RouteNode, list: RouteEdge[], end: 'source' | 'target') {
+ const sorted = [...list].sort((a, b) => farX(a, end) - farX(b, end))
+ const gap = Math.min(PORT_GAP, (node.halfW * 1.4) / Math.max(1, sorted.length - 1))
+ sorted.forEach((e, k) =>
+ (end === 'source' ? sourceOffset : targetOffset).set(
+ e.id,
+ (k - (sorted.length - 1) / 2) * gap
+ )
+ )
+ }
+ for (const n of nodes) {
+ spread(
+ n,
+ routable.filter((e) => e.source === n.id),
+ 'source'
+ )
+ spread(
+ n,
+ routable.filter((e) => e.target === n.id),
+ 'target'
+ )
+ }
+
+ // Horizontal runs, keyed by the gap they sit in (gap g lies above layer g).
+ const segments: Segment[] = []
+ for (const e of routable) {
+ const s = byId.get(e.source)!
+ const t = byId.get(e.target)!
+ const sx = s.cx + sourceOffset.get(e.id)!
+ const tx = t.cx + targetOffset.get(e.id)!
+ const lx = laneX.get(e.id)
+ if (lx == undefined) {
+ // Turn in the gap below the source, then down the target's column — the
+ // column the caller checked clear of the nodes in between.
+ segments.push(seg(e.id, layerOf(s) + 1, sx, tx, 'lower'))
+ } else {
+ segments.push(seg(e.id, layerOf(s) + 1, sx, lx, 'upper'))
+ segments.push(seg(e.id, layerOf(t), lx, tx, 'lower'))
+ }
+ }
+
+ const heights = new Map()
+ const byGap = new Map()
+ for (const sg of segments) byGap.set(sg.gap, [...(byGap.get(sg.gap) ?? []), sg])
+ for (const [gap, list] of byGap) {
+ const tracks = assignTracks(list)
+ const count = Math.max(...tracks.values()) + 1
+ const bandTop = layerTops[gap] - layerGap + GAP_MARGIN_TOP
+ const bandBottom = layerTops[gap] - GAP_MARGIN_BOTTOM
+ const step = Math.min(MAX_TRACK_GAP, (bandBottom - bandTop) / Math.max(1, count))
+ // Centered in the band: a lone run sits midway between the rows.
+ const first = (bandTop + bandBottom) / 2 - ((count - 1) * step) / 2
+ for (const sg of list) heights.set(`${sg.edgeId}:${sg.which}`, first + tracks.get(sg)! * step)
+ }
+
+ const routeOf = new Map()
+ for (const e of routable) {
+ const lx = laneX.get(e.id)
+ routeOf.set(pairKey(e), {
+ sourceOffset: sourceOffset.get(e.id)!,
+ targetOffset: targetOffset.get(e.id)!,
+ y: heights.get(`${e.id}:lower`)!,
+ ...(lx != undefined ? { lane: { x: lx, y: heights.get(`${e.id}:upper`)! } } : {})
+ })
+ }
+ const out = new Map()
+ for (const e of edges) {
+ const r = routeOf.get(pairKey(e))
+ if (r) out.set(e.id, r)
+ }
+ return out
+}
+
+function seg(
+ edgeId: string,
+ gap: number,
+ topPin: number,
+ bottomPin: number,
+ which: Segment['which']
+): Segment {
+ return {
+ edgeId,
+ gap,
+ from: Math.min(topPin, bottomPin),
+ to: Math.max(topPin, bottomPin),
+ topPin,
+ bottomPin,
+ which
+ }
+}
+
+const spans = (sg: Segment, x: number) => x > sg.from + 0.5 && x < sg.to - 0.5
+const overlaps = (a: Segment, b: Segment) => a.from < b.to + PORT_GAP && b.from < a.to + PORT_GAP
+
+/** Track per segment, 0 the highest. */
+function assignTracks(list: Segment[]): Map {
+ // below.get(a) = runs that must sit below a.
+ const below = new Map>(list.map((s) => [s, new Set()]))
+ const indeg = new Map(list.map((s) => [s, 0]))
+ const addAbove = (upper: Segment, lower: Segment) => {
+ if (upper === lower || below.get(upper)!.has(lower)) return
+ below.get(upper)!.add(lower)
+ indeg.set(lower, indeg.get(lower)! + 1)
+ }
+ for (const a of list) {
+ for (const b of list) {
+ if (a === b) continue
+ // a comes down into the gap where b runs: a's run must be above b's.
+ if (spans(b, a.topPin)) addAbove(a, b)
+ // a leaves the gap downward where b runs: a's run must be below b's.
+ if (spans(b, a.bottomPin)) addAbove(b, a)
+ }
+ }
+ // Topological order; a cycle (two runs each needing to be above the other)
+ // cannot be crossing-free, so the least constrained run goes first.
+ const order: Segment[] = []
+ const done = new Set()
+ while (order.length < list.length) {
+ let next = list.find((s) => !done.has(s) && indeg.get(s) === 0)
+ if (!next) {
+ for (const s of list)
+ if (!done.has(s) && (!next || indeg.get(s)! < indeg.get(next)!)) next = s
+ }
+ done.add(next!)
+ order.push(next!)
+ for (const l of below.get(next!)!) indeg.set(l, indeg.get(l)! - 1)
+ }
+ const track = new Map()
+ for (const s of order) {
+ let t = 0
+ for (const [upper, lowers] of below) {
+ if (lowers.has(s) && track.has(upper)) t = Math.max(t, track.get(upper)! + 1)
+ }
+ while ([...track].some(([o, ot]) => ot === t && overlaps(o, s))) t++
+ track.set(s, t)
+ }
+ return track
+}
+
+/** SVG path through orthogonal points, each corner rounded by up to `radius`. */
+export function roundedPath(points: Array<[number, number]>, radius: number): string {
+ const pts = points.filter(
+ (p, i) => i === 0 || p[0] !== points[i - 1][0] || p[1] !== points[i - 1][1]
+ )
+ if (pts.length < 2) return ''
+ let d = `M ${pts[0][0]} ${pts[0][1]}`
+ for (let i = 1; i < pts.length - 1; i++) {
+ const [px, py] = pts[i - 1]
+ const [cx, cy] = pts[i]
+ const [nx, ny] = pts[i + 1]
+ const inLen = Math.hypot(cx - px, cy - py)
+ const outLen = Math.hypot(nx - cx, ny - cy)
+ const r = Math.min(radius, inLen / 2, outLen / 2)
+ const ax = cx - ((cx - px) / inLen) * r
+ const ay = cy - ((cy - py) / inLen) * r
+ const bx = cx + ((nx - cx) / outLen) * r
+ const by = cy + ((ny - cy) / outLen) * r
+ d += ` L ${ax} ${ay} Q ${cx} ${cy} ${bx} ${by}`
+ }
+ const last = pts[pts.length - 1]
+ return `${d} L ${last[0]} ${last[1]}`
+}
+
+/** The point halfway along a polyline, where an edge's badges sit. */
+export function polylineMidpoint(points: Array<[number, number]>): [number, number] {
+ const lens = points.slice(1).map((p, i) => Math.hypot(p[0] - points[i][0], p[1] - points[i][1]))
+ let half = lens.reduce((a, b) => a + b, 0) / 2
+ for (let i = 0; i < lens.length; i++) {
+ if (half <= lens[i] && lens[i] > 0) {
+ const [ax, ay] = points[i]
+ const [bx, by] = points[i + 1]
+ return [ax + ((bx - ax) * half) / lens[i], ay + ((by - ay) * half) / lens[i]]
+ }
+ half -= lens[i]
+ }
+ return points[points.length - 1]
+}
diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts
index e1e1e1e7da..e85c2ac3be 100644
--- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts
+++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts
@@ -11,7 +11,7 @@ const NODE_HEIGHT = NODE.height + 30
* the `NODE_HEIGHT` row the layout reserves. */
export const PIPELINE_NODE_HEIGHT = 48
// A step more than the flow editor's gap: the + on a node's bottom edge sits in it.
-const LAYER_GAP = NODE.gap.vertical + 16
+export const LAYER_GAP = NODE.gap.vertical + 16
/** Height of the "Add data source" pill the anchor draws, and its gap to the
* graph: tighter than LAYER_GAP, since no edge runs through it. */
const ANCHOR_HEIGHT = 32