feat: route pipeline graph edges orthogonally with spread ports, separate tracks and lanes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 10:45:26 +02:00
co-authored by Claude Opus 5.5
parent 69e8445b2c
commit 1bf5333a90
5 changed files with 430 additions and 74 deletions
@@ -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.
@@ -1,10 +1,11 @@
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import { polylineMidpoint, roundedPath, type EdgeRoute } from './assetGraphEdgeRouting'
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
const CORNER = 10
let {
sourceX,
sourceY,
@@ -24,12 +25,41 @@
let testsStatus = $derived(
(data as { testsRunStatus?: 'running' | 'success' | 'failure' } | undefined)?.testsRunStatus
)
// Badge position on the link. A detoured edge runs its vertical segment in
// the gutter lane at `detourX`, so anchor the badge there (not on the
// straight-line midpoint, which would float off the routed path).
let detourX = $derived((data as { detourX?: number } | undefined)?.detourX)
let labelX = $derived(detourX ?? (sourceX + targetX) / 2)
let labelY = $derived((sourceY + targetY) / 2)
// The route the canvas computed for this edge (see assetGraphEdgeRouting):
// spread ports, the height of each horizontal run and, for an edge that
// skips layers, the lane beside the nodes in between.
let route = $derived((data as { route?: EdgeRoute } | undefined)?.route)
let points = $derived.by((): Array<[number, number]> | undefined => {
if (!route) return undefined
const sx = sourceX + route.sourceOffset
const tx = targetX + route.targetOffset
// The layout reserves each row's height, but a node can render shorter or
// taller; keep every run clear of the edge's own endpoints.
const clamp = (y: number) =>
Math.min(Math.max(y, sourceY + 8), Math.max(sourceY + 8, targetY - 8))
if (route.lane) {
const y1 = Math.max(route.lane.y, sourceY + 8)
const y2 = Math.min(Math.max(route.y, y1), targetY - 8)
return [
[sx, sourceY],
[sx, y1],
[route.lane.x, y1],
[route.lane.x, y2],
[tx, y2],
[tx, targetY]
]
}
const y = clamp(route.y)
return [
[sx, sourceY],
[sx, y],
[tx, y],
[tx, targetY]
]
})
let mid = $derived(points ? polylineMidpoint(points) : undefined)
let labelX = $derived(mid?.[0] ?? (sourceX + targetX) / 2)
let labelY = $derived(mid?.[1] ?? (sourceY + targetY) / 2)
function fmtTest(t: DataTest): string {
switch (t.type) {
@@ -99,79 +129,20 @@
: ''
)
// An edge that skips at least one full layer (source-bottom → target-top
// gap larger than gap + node row) while staying near-vertical runs
// straight under the nodes in between — the child-and-grandchild-of-the-
// same-node case, where the tidy-tree layout puts all three on one
// column. Detour those through the gutter beside the column.
const SKIP_DY = NODE.gap.vertical * 2 + PIPELINE_NODE_HEIGHT
const NEAR_VERTICAL_DX = NODE.width / 4
// Lane offset: half a node plus a margin that stays inside the
// inter-column gutter (gap.horizontal / 2).
const LANE = NODE.width / 2 + NODE.gap.horizontal / 2
const CORNER = 14
// Rounded-orthogonal detour: drop out of the source, swing into the
// gutter lane at `midX`, run it down past the intermediate rows, swing
// back into the target.
function gutterPath(midX: number): string {
const yTop = sourceY + 24
const yBot = targetY - 24
const outDir = midX > sourceX ? 1 : -1
const inDir = targetX > midX ? 1 : -1
return [
`M ${sourceX} ${sourceY}`,
`L ${sourceX} ${yTop - CORNER}`,
`Q ${sourceX} ${yTop} ${sourceX + outDir * CORNER} ${yTop}`,
`L ${midX - outDir * CORNER} ${yTop}`,
`Q ${midX} ${yTop} ${midX} ${yTop + CORNER}`,
`L ${midX} ${yBot - CORNER}`,
`Q ${midX} ${yBot} ${midX + inDir * CORNER} ${yBot}`,
`L ${targetX - inDir * CORNER} ${yBot}`,
`Q ${targetX} ${yBot} ${targetX} ${yBot + CORNER}`,
`L ${targetX} ${targetY}`
].join(' ')
}
// Long edges bend out of the source within the layer gap and descend in
// the *target* column. The flow editor's pattern (descend at the source,
// bend at the bottom) assumed the source column below was free — in the
// banded tree layout it's where the source's own children live, so the
// straight segment ran under them (e.g. fx_rates → daily_revenue under
// fx_rates' other subtree). Long edges only target joins here (a single-
// parent child is always exactly one layer below its parent), and the
// space above a join is the seam between its parents' bands — clear by
// construction.
let edgePath = $derived.by(() => {
const dy = targetY - sourceY
// Obstacle-aware detour: the canvas detected this edge's straight run
// would pass over an unrelated node and chose a clear lane. Route there.
const detourX = (data as { detourX?: number } | undefined)?.detourX
if (detourX != undefined) {
return gutterPath(detourX)
}
if (dy > SKIP_DY && Math.abs(targetX - sourceX) < NEAR_VERTICAL_DX) {
// Near-vertical same-column skip: lane just beside the column, past
// both endpoints so the horizontal runs never degenerate.
const side = targetX >= sourceX ? 1 : -1
return gutterPath(
side > 0 ? Math.max(sourceX, targetX) + LANE : Math.min(sourceX, targetX) - LANE
)
}
const bendY = sourceY + Math.min(100, NODE.gap.vertical)
const long = dy > 100
if (points) return roundedPath(points, CORNER)
// No route: an edge pointing up (a cycle's feedback edge), which the
// layered routing has no gap for.
const [bezier] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY: long ? bendY : targetY,
targetY,
targetPosition,
curvature: 0.25
})
// The bezier ends at (targetX, bendY); continue straight down the
// target column into the node.
return long ? `${bezier} L${targetX},${targetY}` : bezier
return bezier
})
</script>
@@ -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)
})
})
@@ -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<string, EdgeRoute> {
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<string, RouteEdge>()
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<string, number>()
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<string, number>()
const targetOffset = new Map<string, number>()
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<string, number>()
const byGap = new Map<number, Segment[]>()
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<string, EdgeRoute>()
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<string, EdgeRoute>()
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<Segment, number> {
// below.get(a) = runs that must sit below a.
const below = new Map<Segment, Set<Segment>>(list.map((s) => [s, new Set()]))
const indeg = new Map<Segment, number>(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<Segment>()
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<Segment, number>()
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]
}
@@ -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