revert: pipeline graph edges back to bezier curves, detours curved too

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 12:42:04 +02:00
co-authored by Claude Opus 5.5
parent 0cbfc83366
commit 549f0427f1
5 changed files with 116 additions and 964 deletions
@@ -23,14 +23,11 @@
import PanToNode from './PanToNode.svelte'
import InitialFitView from './InitialFitView.svelte'
import {
LAYER_GAP,
layoutAssetGraph,
layoutRowHeight,
PIPELINE_NODE_EXTRA_ROW,
PIPELINE_NODE_HEADER,
PIPELINE_NODE_HEADER_CARD_PAD
} from './assetGraphLayout'
import { detourLane, routeAssetGraphEdges, type EdgeRoute } from './assetGraphEdgeRouting'
import {
assetsOnlyView,
upstreamDeletion,
@@ -1680,10 +1677,15 @@
: 1
)
})
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,
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
@@ -1859,52 +1861,34 @@
halfW: nodeWidth(n) / 2
}))
)
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.
// 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.
const ROUTE_PAD = NODE.gap.horizontal / 2
function detourForEdge(sourceId: string, targetId: string): number | undefined {
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
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
}
// Routing depends on geometry and edges only. The nodes are rebuilt on every
// selection or run update, so the routes are kept until either really changes.
let routeInput = $derived.by(() => {
const edges = view.edges
.filter((e) => e.kind !== 'add-anchor')
.map((e) => ({
id: e.id,
source: e.source,
target: 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}`
}))
return { nodes: routeNodes, edges, key: JSON.stringify([routeNodes, edges]) }
})
let routedFor: { key: string; routes: Map<string, EdgeRoute> } | undefined
let edgeRoutes = $derived.by(() => {
const { nodes, edges, key } = routeInput
if (routedFor?.key === key) return routedFor.routes
const routes = routeAssetGraphEdges(
nodes,
edges.map((e) => ({ ...e, laneX: detourForEdge(e.source, e.target) })),
LAYER_GAP
)
routedFor = { key, routes }
return routes
})
let flowEdges = $derived.by(() =>
view.edges
// Anchor edges are layout-only.
@@ -2051,7 +2035,7 @@
target: e.target,
type: 'asset',
data: {
route: edgeRoutes.get(e.id),
detourX: detourForEdge(e.source, e.target),
// 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,14 +1,11 @@
<script lang="ts">
import { BaseEdge, getBezierPath, useInternalNode, type EdgeProps } from '@xyflow/svelte'
import { polylineMidpoint, roundedPath, type EdgeRoute } from './assetGraphEdgeRouting'
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
const CORNER = 10
let {
source,
target,
sourceX,
sourceY,
sourcePosition,
@@ -27,64 +24,12 @@
let testsStatus = $derived(
(data as { testsRunStatus?: 'running' | 'success' | 'failure' } | undefined)?.testsRunStatus
)
// 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)
// A side end sits at the node's rendered mid-height, which only the flow knows.
const sourceNode = useInternalNode(source)
const targetNode = useInternalNode(target)
function sidePoint(
node: typeof sourceNode.current,
side: 'left' | 'right'
): [number, number] | undefined {
const pos = node?.internals.positionAbsolute
const w = node?.measured.width
const h = node?.measured.height
if (!pos || !w || !h) return undefined
return [side === 'left' ? pos.x : pos.x + w, pos.y + h / 2]
}
let points = $derived.by((): Array<[number, number]> | undefined => {
if (!route) return undefined
const pts = route.points.map(([x, y]): [number, number] => [x, y])
const n = pts.length
// The ends sit on this edge's handles, or on a side of its node. The
// layout reserves each row's height, but a node can render shorter or
// taller: keep every other turn clear of the edge's own ends.
const fixed = new Set<number>([0, n - 1])
if (route.sourceSide) {
const at = sidePoint(sourceNode.current, route.sourceSide)
if (at) {
pts[0] = at
pts[1][1] = at[1]
}
fixed.add(1)
} else pts[0][1] = sourceY
if (route.targetSide) {
const at = sidePoint(targetNode.current, route.targetSide)
if (at) {
pts[n - 1] = at
pts[n - 2][1] = at[1]
}
fixed.add(n - 2)
} else pts[n - 1][1] = targetY
const lo = sourceY + 8
const hi = Math.max(lo, targetY - 8)
for (let k = 0; k < n; k++) if (!fixed.has(k)) pts[k][1] = Math.min(Math.max(pts[k][1], lo), hi)
return pts
})
// Dots where this edge merges with or splits from others, so a junction never
// reads as a crossing. Clamped like the turns they sit on.
let junctions = $derived(
(route?.junctions ?? []).map(([x, y]): [number, number] => [
x,
Math.min(Math.max(y, sourceY + 8), Math.max(sourceY + 8, targetY - 8))
])
)
let strokeColor = $derived(/stroke:\s*([^;]+)/.exec(style ?? '')?.[1]?.trim() ?? 'currentColor')
let mid = $derived(points ? polylineMidpoint(points) : undefined)
let labelX = $derived(mid?.[0] ?? (sourceX + targetX) / 2)
let labelY = $derived(mid?.[1] ?? (sourceY + targetY) / 2)
// 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)
function fmtTest(t: DataTest): string {
switch (t.type) {
@@ -154,21 +99,86 @@
: ''
)
let edgePath = $derived.by(() => {
// Sharp at junctions, so the lines meet on the dot rather than curving off it.
if (points) return roundedPath(points, CORNER, junctions)
// No route: an edge pointing up (a cycle's feedback edge), which the
// layered routing has no gap for.
const [bezier] = getBezierPath({
// 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
// Detour through the gutter lane at `midX`, drawn with the same curves as
// every other edge: curve out of the source into the lane within the gap
// below it, run down the lane past the rows in between, curve into the
// target within the gap above it.
function gutterPath(midX: number): string {
const bend = NODE.gap.vertical
const yTop = sourceY + bend
const yBot = Math.max(yTop, targetY - bend)
const [out] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX: midX,
targetY: yTop,
targetPosition,
curvature: 0.25
})
const [into] = getBezierPath({
sourceX: midX,
sourceY: yBot,
sourcePosition,
targetX,
targetY,
targetPosition,
curvature: 0.25
})
return bezier
// Continue from the lane rather than lifting the pen.
return `${out} L${midX},${yBot} ${into.replace(/^M/, 'L')}`
}
// 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
const [bezier] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY: long ? bendY : 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
})
</script>
@@ -180,9 +190,6 @@
label={undefined}
labelStyle={undefined}
/>
{#each junctions as [x, y] (`${x}:${y}`)}
<circle cx={x} cy={y} r="2.5" fill={strokeColor} />
{/each}
{#if tests && tests.length > 0}
<!-- Badge centered on the link midpoint. Edges render in the SVG layer, so
@@ -1,204 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
detourLane,
routeAssetGraphEdges,
roundedPath,
type EdgeRoute,
type RouteEdge,
type RouteNode
} from './assetGraphEdgeRouting'
const GAP = 80
const node = (id: string, cx: number, top: number): RouteNode => ({
id,
cx,
top,
halfW: 100,
height: 60
})
const edge = (source: string, target: string, style = 'read'): RouteEdge => ({
id: `${source}-${target}`,
source,
target,
style
})
type Pt = [number, number]
const segments = (pts: Pt[]) => pts.slice(1).map((p, i) => [pts[i], p] as const)
// Two axis-aligned segments drawn over each other for a positive length.
function shareLength(a: readonly [Pt, Pt], b: readonly [Pt, Pt]): boolean {
const vertical = (s: readonly [Pt, Pt]) => s[0][0] === s[1][0]
if (vertical(a) !== vertical(b)) return false
const [axis, along] = vertical(a) ? [0, 1] : [1, 0]
if (a[0][axis] !== b[0][axis]) return false
const lo = Math.max(Math.min(a[0][along], a[1][along]), Math.min(b[0][along], b[1][along]))
const hi = Math.min(Math.max(a[0][along], a[1][along]), Math.max(b[0][along], b[1][along]))
return hi - lo > 0.5
}
/** The rule that keeps merged lines readable: a segment two edges draw over
* each other belongs to edges sharing a source or a target. */
function expectNoAmbiguousSegment(edges: RouteEdge[], routes: Map<string, EdgeRoute>) {
for (const e of edges)
for (const f of edges) {
if (e === f || e.source === f.source || e.target === f.target) continue
for (const a of segments(routes.get(e.id)!.points))
for (const b of segments(routes.get(f.id)!.points))
expect(shareLength(a, b), `${e.id} and ${f.id} share a segment`).toBe(false)
}
}
const endX = (r: Map<string, EdgeRoute>, id: string) => r.get(id)!.points.at(-1)![0]
describe('routeAssetGraphEdges', () => {
it('merges a fan-in into one trunk entering the target', () => {
const nodes = [node('a', 0, 0), node('b', 300, 0), node('c', 600, 0), node('t', 300, 200)]
const edges = [edge('a', 't'), edge('b', 't'), edge('c', 't')]
const r = routeAssetGraphEdges(nodes, edges, GAP)
expect(new Set(edges.map((e) => endX(r, e.id))).size).toBe(1)
// The bus is marked where the outer edges join it.
expect(r.get('b-t')!.junctions.length).toBeGreaterThan(0)
expectNoAmbiguousSegment(edges, r)
})
it('merges a fan-out into one drop leaving the source', () => {
const nodes = [node('s', 300, 0), node('a', 0, 200), node('b', 300, 200), node('c', 600, 200)]
const edges = [edge('s', 'a'), edge('s', 'b'), edge('s', 'c')]
const r = routeAssetGraphEdges(nodes, edges, GAP)
expect(new Set(edges.map((e) => r.get(e.id)!.points[0][0])).size).toBe(1)
expectNoAmbiguousSegment(edges, r)
})
it('merges long edges into one target even when their sources fan out', () => {
// Two sources each feed a node right below and, past it, a node further
// down: the long edges share a target, so they may share their lane.
const nodes = [
node('events', 0, 0),
node('persons', 600, 0),
node('merged', 300, 200),
node('rollup', 300, 400)
]
const edges = [
edge('events', 'merged'),
edge('persons', 'merged'),
{ ...edge('events', 'rollup'), laneX: 150 },
{ ...edge('persons', 'rollup'), laneX: 150 },
edge('merged', 'rollup')
]
const r = routeAssetGraphEdges(nodes, edges, GAP)
const lane = (id: string) => r.get(id)!.points.find(([x, y]) => y > 200 && y < 400)?.[0]
expect(lane('events-rollup')).toBe(lane('persons-rollup'))
expect(endX(r, 'events-rollup')).toBe(endX(r, 'persons-rollup'))
expect(endX(r, 'events-merged')).toBe(endX(r, 'persons-merged'))
expectNoAmbiguousSegment(edges, r)
})
it('never merges edges with different sources and different targets', () => {
const nodes = [node('s1', 0, 0), node('s2', 600, 0), node('t1', 0, 200), node('t2', 600, 200)]
const edges = [edge('s1', 't1'), edge('s1', 't2'), edge('s2', 't1'), edge('s2', 't2')]
expectNoAmbiguousSegment(edges, routeAssetGraphEdges(nodes, edges, GAP))
})
it('keeps edges drawn differently apart', () => {
const nodes = [node('a', 0, 0), node('b', 600, 0), node('t', 300, 200)]
const r = routeAssetGraphEdges(nodes, [edge('a', 't', 'read'), edge('b', 't', 'trigger')], GAP)
expect(endX(r, 'a-t')).not.toBe(endX(r, 'b-t'))
})
it('draws a lone edge between overlapping nodes as one straight line', () => {
const r = routeAssetGraphEdges([node('s', 0, 0), node('t', 40, 200)], [edge('s', 't')], GAP)
const pts = r.get('s-t')!.points
expect(pts).toHaveLength(2)
expect(pts[0][0]).toBe(pts[1][0])
})
it('enters a side to save a turn when nothing is in the way', () => {
const r = routeAssetGraphEdges([node('s', 0, 0), node('t', 600, 200)], [edge('s', 't')], GAP)
const route = r.get('s-t')!
// Two turns through the gap become one, by a side of either node.
expect(route.points).toHaveLength(3)
expect(route.sourceSide ?? route.targetSide).toBeDefined()
})
it('takes no side shortcut through another node', () => {
// Leaving s sideways would run through b; entering t sideways would
// come down through c.
const r = routeAssetGraphEdges(
[node('s', 0, 0), node('b', 300, 0), node('c', 600, 0), node('t', 300, 200)],
[edge('s', 't')],
GAP
)
const route = r.get('s-t')!
expect(route.sourceSide).toBeUndefined()
})
it('offsets lanes of unrelated edges', () => {
const nodes = [
node('s1', 0, 0),
node('s2', 0, 0),
node('mid', 0, 200),
node('t1', -50, 400),
node('t2', 50, 400)
]
const r = routeAssetGraphEdges(
nodes,
[
{ ...edge('s1', 't1'), laneX: 150 },
{ ...edge('s2', 't2'), laneX: 150 }
],
GAP
)
expect(r.get('s1-t1')!.points[2][0]).not.toBe(r.get('s2-t2')!.points[2][0])
})
it('gives edges joining the same two nodes one route', () => {
const r = routeAssetGraphEdges(
[node('s', 0, 0), node('t', 0, 200)],
[edge('s', 't', 'read'), { ...edge('s', 't', 'trigger'), id: 'trigger' }],
GAP
)
expect(r.get('trigger')).toEqual(r.get('s-t'))
})
it('leaves an upward edge unrouted', () => {
const r = routeAssetGraphEdges(
[node('up', 0, 0), node('down', 0, 200)],
[edge('down', 'up')],
GAP
)
expect(r.has('down-up')).toBe(false)
})
})
describe('detourLane', () => {
it('clears every node in the way, not only the first one found', () => {
// The target's column is blocked by two nodes on different rows; the lane
// past the nearer one alone would still run through the other.
const source = node('s', -200, 0)
const target = node('t', 0, 600)
const a = { ...node('a', 150, 200), halfW: 180 }
const b = { ...node('b', 80, 400), halfW: 180 }
const lane = detourLane(source, target, [source, target, a, b], 25)!
for (const n of [a, b]) expect(Math.abs(lane - n.cx)).toBeGreaterThan(n.halfW)
})
it('needs no lane when the column is clear', () => {
const source = node('s', 0, 0)
const target = node('t', 0, 400)
expect(detourLane(source, target, [source, target, node('a', 500, 200)], 25)).toBeUndefined()
})
})
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)
})
})
@@ -1,630 +0,0 @@
// Orthogonal edge routing for the asset graph. Every edge is drawn the same way
// — vertical runs joined by horizontal runs with rounded corners — by these
// rules, in order:
//
// 1. Pairs: edges joining the same two nodes (a read and the trigger it
// derives) share one route and are drawn over each other.
// 2. Bundles: edges of one style into one node gather on a bus and enter it
// through one trunk (fan-in); edges of one style out of one node leave
// through one drop and split on a bus (fan-out). Long edges bundle too: those
// of a fan-in coming down the same lane share it. A shared segment only ever
// carries edges with the same target (fan-in) or the same source (fan-out),
// never different sources and different targets, so any merged line still
// reads back to its ends. An edge belongs to one bundle at most: a long edge
// prefers its target's, where the long runs are, a short one the larger. A
// long edge whose lane comes down right over its target, or right beside it,
// stays out: it enters straight, or by the side, and merging would only add
// turns.
// 3. Junctions: where lines merge or split, the route carries a point for a dot,
// so a junction never reads as two lines crossing.
// 4. Style: only edges drawn alike merge, so none hides another.
// 5. Ports: what still leaves (enters) a node separately — single edges and
// bundles — gets its own point on its bottom (top) edge, as close to where it
// heads as the node allows: an edge whose ends overlap runs straight.
// 6. Tracks: the horizontal runs sharing the gap between two layers get distinct
// heights, ordered by the channel-routing constraint that removes avoidable
// crossings: a run sits 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.
// 7. Lanes: an edge that would cross a node between its ends comes down a lane
// beside them (chosen by the caller); lanes shared by unrelated edges sit
// side by side.
// 8. Sides: an edge may leave its source by a side, at mid-height, or enter its
// target by one, when that saves a turn, the shortcut crosses no node, and
// that end of its route is its own (not a bundle's shared drop or trunk). Nodes always sit below what they depend on and
// the arrow points into the target, so the direction stays plain. A side
// takes one edge.
//
// 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
/** Height the layout reserves for it: an upper bound of the rendered one. */
height: 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
/** How the edge is drawn: only edges drawn alike merge. */
style: string
}
/** Orthogonal points from the source handle to the target handle — the first
* and last y are placeholders the edge replaces with its handles' y — and the
* junctions on it where it merges with or splits from other edges. */
export type EdgeRoute = {
points: Array<[number, number]>
junctions: Array<[number, number]>
/** The edge leaves its source by this side, at mid-height, instead of its bottom. */
sourceSide?: 'left' | 'right'
/** The edge enters its target by this side, at mid-height, instead of its top. */
targetSide?: 'left' | 'right'
}
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
// How far from its center, as a share of its half width, a node's ports may sit.
const PORT_SPAN = 0.85
// Least distance between a port's line and an unrelated line beside it.
const AVOID = 8
type Pair = {
key: string
source: RouteNode
target: RouteNode
laneX?: number
/** The styles its edges are drawn in: pairs merge only when theirs match. */
style: string
edgeIds: string[]
}
/** A horizontal line in one gap, with the x's where lines come down into it and
* where they leave it downward. */
type Net = { gap: number; topPins: number[]; bottomPins: number[]; from: number; to: number }
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)
// 1. Pairs.
const pairs = new Map<string, Pair>()
for (const e of edges) {
const s = byId.get(e.source)
const t = byId.get(e.target)
if (!s || !t || t.top <= s.top) continue
const key = `${e.source}\u0000${e.target}`
const p = pairs.get(key)
if (p) {
p.edgeIds.push(e.id)
p.style = [...new Set([...p.style.split('\u0001'), e.style])].sort().join('\u0001')
if (p.laneX == undefined) p.laneX = e.laneX
} else {
pairs.set(key, { key, source: s, target: t, laneX: e.laneX, style: e.style, edgeIds: [e.id] })
}
}
const all = [...pairs.values()]
const adjacent = (p: Pair) => layerOf(p.target) === layerOf(p.source) + 1
const SIDE_CLEAR = 6
const crossesNode = (x1: number, y1: number, x2: number, y2: number, except: RouteNode[]) =>
nodes.some(
(n) =>
!except.includes(n) &&
Math.min(x1, x2) < n.cx + n.halfW + SIDE_CLEAR &&
Math.max(x1, x2) > n.cx - n.halfW - SIDE_CLEAR &&
Math.min(y1, y2) < n.top + n.height + SIDE_CLEAR &&
Math.max(y1, y2) > n.top - SIDE_CLEAR
)
const mid = (n: RouteNode) => n.top + n.height / 2
// 2. Bundles.
const outsOf = new Map<string, Pair[]>()
const insOf = new Map<string, Pair[]>()
for (const p of all) {
outsOf.set(p.source.id, [...(outsOf.get(p.source.id) ?? []), p])
insOf.set(p.target.id, [...(insOf.get(p.target.id) ?? []), p])
}
const inKey = (p: Pair) => `in\u0000${p.target.id}\u0000${p.style}`
const outKey = (p: Pair) => `out\u0000${p.source.id}\u0000${p.style}`
const bundleOf = new Map<Pair, string>()
const sizeOf = (key: string) => [...bundleOf.values()].filter((b) => b === key).length
// Candidates are the pairs sharing an end and a style; a bundle needs two.
// A long edge whose lane already comes down over its target goes straight
// in: gathering it with others would only add turns.
const straightIn = (p: Pair) =>
!adjacent(p) &&
p.laneX != undefined &&
Math.abs(p.laneX - p.target.cx) <= p.target.halfW * PORT_SPAN
// A long edge whose lane passes right beside its target, with a clear way in,
// enters by that side: one turn, where a bus would take two. Only the one
// edge coming that way: a side takes one edge, and several sharing a lane
// are better merged.
const sideOf = (p: Pair) => (p.laneX! < p.target.cx ? 'left' : 'right')
const sideIn = (p: Pair) => {
if (adjacent(p) || p.laneX == undefined || straightIn(p)) return false
const t = p.target
const rivals = insOf
.get(t.id)!
.filter((q) => !adjacent(q) && q.laneX != undefined && !straightIn(q) && sideOf(q) === sideOf(p))
if (rivals.length > 1) return false
const sideX = sideOf(p) === 'left' ? t.cx - t.halfW : t.cx + t.halfW
return (
Math.abs(p.laneX - sideX) <= layerGap &&
!crossesNode(p.laneX, t.top - layerGap, p.laneX, mid(t), [p.source, t]) &&
!crossesNode(p.laneX, mid(t), sideX, mid(t), [t])
)
}
const fanInPeers = (p: Pair) =>
insOf.get(p.target.id)!.filter((q) => q.style === p.style && !straightIn(q) && !sideIn(q))
const fanOutPeers = (p: Pair) => outsOf.get(p.source.id)!.filter((q) => q.style === p.style)
for (const p of all) {
const canIn = !straightIn(p) && !sideIn(p) && fanInPeers(p).length >= 2
const canOut = fanOutPeers(p).length >= 2
if (canIn && (!canOut || !adjacent(p) || fanInPeers(p).length >= fanOutPeers(p).length))
bundleOf.set(p, inKey(p))
else if (canOut) bundleOf.set(p, outKey(p))
}
// A bundle left with one member is just an edge; that edge may still join
// the other bundle it could have been in. Repeat until nothing moves.
for (let changed = true; changed; ) {
changed = false
for (const [p, b] of [...bundleOf]) {
if (sizeOf(b) >= 2) continue
bundleOf.delete(p)
const other = b === inKey(p) ? outKey(p) : inKey(p)
const peers = b === inKey(p) ? fanOutPeers(p) : fanInPeers(p)
if (peers.some((q) => q !== p && bundleOf.get(q) === other)) bundleOf.set(p, other)
changed = true
}
}
const fanIn = (p: Pair) => bundleOf.get(p)?.startsWith('in\u0000') ?? false
const fanOut = (p: Pair) => bundleOf.get(p)?.startsWith('out\u0000') ?? false
// 7. Lanes. One corridor per distinct lane user: a fan-in's members coming
// down the same lane share it; any other laned edge has its own. Corridors
// that land within a few pixels of each other are spread side by side,
// ordered by their target's x so they leave without crossing.
const corridorKey = (p: Pair) =>
fanIn(p) ? `${bundleOf.get(p)}\u0000${Math.round(p.laneX! / LANE_GAP)}` : `pair\u0000${p.key}`
const corridors = new Map<string, { laneX: number; targetX: number }>()
for (const p of all) {
if (p.laneX == undefined) continue
if (!corridors.has(corridorKey(p)))
corridors.set(corridorKey(p), { laneX: p.laneX, targetX: p.target.cx })
}
const corridorX = new Map<string, number>()
const sorted = [...corridors].sort((a, b) => a[1].laneX - b[1].laneX)
for (let i = 0; i < sorted.length; ) {
let j = i + 1
while (j < sorted.length && sorted[j][1].laneX - sorted[i][1].laneX < LANE_GAP) j++
const group = sorted.slice(i, j).sort((a, b) => a[1].targetX - b[1].targetX)
group.forEach(([key, c], k) =>
corridorX.set(key, c.laneX + (k - (group.length - 1) / 2) * LANE_GAP)
)
i = j
}
const laneOf = (p: Pair) => (p.laneX == undefined ? undefined : corridorX.get(corridorKey(p)))
// 5. Ports. Sources are placed first, so an edge from the layer right above
// enters under its source's port; a longer non-laned edge enters down the
// target's own column, the one the caller checked clear.
const outItem = (p: Pair) => (fanOut(p) ? bundleOf.get(p)! : `pair\u0000${p.key}`)
const inItem = (p: Pair) => (fanIn(p) ? bundleOf.get(p)! : `pair\u0000${p.key}`)
const portX = new Map<string, number>()
function place(
node: RouteNode,
items: Map<string, number[]>,
side: 'out' | 'in',
avoid: (item: string) => number[] = () => []
) {
const list = [...items].map(([item, xs]) => ({
item,
want: xs.reduce((a, b) => a + b, 0) / xs.length
}))
list.sort((a, b) => a.want - b.want)
const lo = node.cx - node.halfW * PORT_SPAN
const hi = node.cx + node.halfW * PORT_SPAN
const gap = Math.min(PORT_GAP, (hi - lo) / Math.max(1, list.length - 1))
const xs = list.map((e) => Math.min(Math.max(e.want, lo), hi))
for (let k = 1; k < xs.length; k++) xs[k] = Math.max(xs[k], xs[k - 1] + gap)
for (let k = xs.length - 1; k >= 0; k--)
xs[k] = Math.min(xs[k], k === xs.length - 1 ? hi : xs[k + 1] - gap)
// Step aside from lines that are not this port's but pass where its line
// would run: drawn over each other, the two would read as one.
xs.forEach((x, k) => {
const taken = avoid(list[k].item)
const clear = (c: number) =>
c >= lo &&
c <= hi &&
taken.every((f) => Math.abs(f - c) >= AVOID) &&
(k === 0 || c >= xs[k - 1] + gap) &&
(k === xs.length - 1 || c <= xs[k + 1] - gap)
if (clear(x)) return
for (let d = AVOID; d <= node.halfW; d += AVOID / 2) {
if (clear(x + d)) return void (xs[k] = x + d)
if (clear(x - d)) return void (xs[k] = x - d)
}
})
list.forEach(({ item }, k) => portX.set(`${side}\u0000${node.id}\u0000${item}`, xs[k]))
}
for (const n of nodes) {
const outs = new Map<string, number[]>()
for (const p of outsOf.get(n.id) ?? [])
outs.set(outItem(p), [...(outs.get(outItem(p)) ?? []), laneOf(p) ?? p.target.cx])
place(n, outs, 'out')
}
const sx = (p: Pair) => portX.get(`out\u0000${p.source.id}\u0000${outItem(p)}`)!
// Lines running down through the gap above a layer: the ports of the layer
// above and the lanes passing it. A target's own entry may meet those of its
// own edges, never another's.
function linesThroughGapAbove(n: RouteNode): Array<{ x: number; pair: Pair }> {
const L = layerOf(n)
const lines: Array<{ x: number; pair: Pair }> = []
for (const p of all) {
if (layerOf(p.source) === L - 1) lines.push({ x: sx(p), pair: p })
const lx = laneOf(p)
if (lx != undefined && layerOf(p.source) + 1 <= L && L <= layerOf(p.target))
lines.push({ x: lx, pair: p })
}
return lines
}
for (const n of nodes) {
const ins = new Map<string, number[]>()
for (const p of insOf.get(n.id) ?? []) {
const want = laneOf(p) ?? (adjacent(p) ? sx(p) : p.target.cx)
ins.set(inItem(p), [...(ins.get(inItem(p)) ?? []), want])
}
const through = linesThroughGapAbove(n)
place(n, ins, 'in', (item) =>
through
.filter(({ pair }) => pair.target.id !== n.id || inItem(pair) !== item)
.map(({ x }) => x)
)
}
const tx = (p: Pair) => portX.get(`in\u0000${p.target.id}\u0000${inItem(p)}`)!
// Where an edge goes down after its first run: its lane, else the column it
// enters its target by.
const descentX = (p: Pair) => laneOf(p) ?? tx(p)
// Nets: the horizontal lines. A net's key says which edges ride it, and every
// edge on a shared net has the same source or the same target.
const nets = new Map<string, Net>()
type Leg = { key: string; gap: number; from: number; to: number }
const legsOf = new Map<Pair, Leg[]>()
for (const p of all) {
const below = layerOf(p.source) + 1
const lower = layerOf(p.target)
const b = bundleOf.get(p)
const legs: Leg[] = []
const leg = (key: string, gap: number, from: number, to: number) => {
const n = nets.get(key) ?? { gap, topPins: [], bottomPins: [], from: Infinity, to: -Infinity }
n.topPins.push(from)
n.bottomPins.push(to)
n.from = Math.min(n.from, from, to)
n.to = Math.max(n.to, from, to)
nets.set(key, n)
legs.push({ key, gap, from, to })
}
const lx = laneOf(p)
if (fanOut(p)) {
// One drop and one bus for the bundle; a laned member then leaves its
// lane on its own run.
leg(b!, below, sx(p), descentX(p))
if (lx != undefined) leg(`lower\u0000${p.key}`, lower, lx, tx(p))
} else if (fanIn(p)) {
if (adjacent(p)) {
leg(`${b}\u0000bus`, lower, sx(p), tx(p))
} else {
// Members from one row heading down one corridor gather first.
const c = descentX(p)
leg(`${b}\u0000from\u0000${below}\u0000${Math.round(c)}`, below, sx(p), c)
if (lx != undefined) leg(`${b}\u0000bus`, lower, c, tx(p))
}
} else if (lx != undefined) {
leg(`upper\u0000${p.key}`, below, sx(p), lx)
leg(`lower\u0000${p.key}`, lower, lx, tx(p))
} else {
// Turn in the gap below the source, then down the target's column.
leg(`run\u0000${p.key}`, below, sx(p), tx(p))
}
legsOf.set(p, legs)
}
// 6. Tracks.
const height = new Map<string, number>()
const byGap = new Map<number, [string, Net][]>()
for (const entry of nets) byGap.set(entry[1].gap, [...(byGap.get(entry[1].gap) ?? []), entry])
for (const [gap, list] of byGap) {
// A net whose ends meet has no horizontal line to place.
const runs = list.filter(([, n]) => n.to - n.from > 0.5)
if (runs.length === 0) continue
const tracks = assignTracks(runs.map(([, n]) => n))
const count = Math.max(...tracks) + 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
runs.forEach(([key], i) => height.set(key, first + tracks[i] * step))
}
const yOf = (leg: Leg) =>
height.get(leg.key) ?? layerTops[leg.gap] - (layerGap - GAP_MARGIN_TOP + GAP_MARGIN_BOTTOM) / 2
// 3. Junctions: a point on a net where three or more line directions meet —
// a line coming down into it, one leaving it downward, a run left, a run right.
function junctionsOf(n: Net): number[] {
const at = (pins: number[], x: number) => pins.some((p) => Math.abs(p - x) < 0.5)
return [...new Set([...n.topPins, ...n.bottomPins])].filter(
(x) =>
(at(n.topPins, x) ? 1 : 0) +
(at(n.bottomPins, x) ? 1 : 0) +
(x > n.from + 0.5 ? 1 : 0) +
(x < n.to - 0.5 ? 1 : 0) >=
3
)
}
// 8. Sides.
const sideTaken = new Set<string>()
type Shortcut = { points: Array<[number, number]>; side: 'left' | 'right' }
// Come down beside the target to its mid-height and go in sideways, instead
// of turning into the gap above it and down into its top.
function enterBySide(pts: Array<[number, number]>, p: Pair): Shortcut | undefined {
const n = pts.length
if (n < 4) return undefined
const [x, y] = pts[n - 3]
if (pts[n - 2][1] !== y || pts[n - 4][0] !== x) return undefined
const t = p.target
const side = x < t.cx - t.halfW - SIDE_CLEAR ? 'left' : x > t.cx + t.halfW + SIDE_CLEAR ? 'right' : undefined
if (!side || sideTaken.has(`${t.id}:${side}`)) return undefined
const sideX = side === 'left' ? t.cx - t.halfW : t.cx + t.halfW
if (crossesNode(x, y, x, mid(t), [p.source, t]) || crossesNode(x, mid(t), sideX, mid(t), [t]))
return undefined
return { points: [...pts.slice(0, n - 3), [x, mid(t)], [sideX, mid(t)]], side }
}
// Leave the source sideways at its mid-height straight to where the edge goes
// down, instead of dropping into the gap below it and turning there.
function leaveBySide(pts: Array<[number, number]>, p: Pair): Shortcut | undefined {
if (pts.length < 4) return undefined
const [x, y] = pts[2]
if (pts[1][1] !== y || pts[3][0] !== x) return undefined
const s = p.source
const side = x < s.cx - s.halfW - SIDE_CLEAR ? 'left' : x > s.cx + s.halfW + SIDE_CLEAR ? 'right' : undefined
if (!side || sideTaken.has(`${s.id}:${side}`)) return undefined
const sideX = side === 'left' ? s.cx - s.halfW : s.cx + s.halfW
if (crossesNode(sideX, mid(s), x, mid(s), [s]) || crossesNode(x, mid(s), x, y, [s, p.target]))
return undefined
return { points: [[sideX, mid(s)], [x, mid(s)], ...pts.slice(3)], side }
}
const length = (pts: Array<[number, number]>) =>
pts.slice(1).reduce((a, q, i) => a + Math.abs(q[0] - pts[i][0]) + Math.abs(q[1] - pts[i][1]), 0)
const out = new Map<string, EdgeRoute>()
for (const p of all) {
let points: Array<[number, number]> = [[sx(p), p.source.top]]
const junctions: Array<[number, number]> = []
for (const leg of legsOf.get(p)!) {
const y = yOf(leg)
points.push([leg.from, y], [leg.to, y])
// The junctions on this edge's own path: where it joins or leaves the net.
for (const x of junctionsOf(nets.get(leg.key)!))
if (Math.abs(x - leg.from) < 0.5 || Math.abs(x - leg.to) < 0.5) junctions.push([x, y])
}
points.push([tx(p), p.target.top])
points = dropCollinear(points)
let sourceSide: 'left' | 'right' | undefined
let targetSide: 'left' | 'right' | undefined
// Only an end the edge has to itself may move to a side: a fan-out member
// owns its way into its target only past its own lane (short of it, the
// line it would come down is the bundle's drop); a fan-in member owns
// neither end once it joins the others.
if (!fanIn(p)) {
// A route with a turn at each end (a laned one) may shortcut both; a
// single run takes whichever shortcut is shorter.
const enter = fanOut(p) && laneOf(p) == undefined ? undefined : enterBySide(points, p)
const leave = bundleOf.has(p) ? undefined : leaveBySide(points, p)
const both = points.length >= 6
if (enter && (both || !leave || length(enter.points) <= length(leave.points))) {
points = enter.points
targetSide = enter.side
sideTaken.add(`${p.target.id}:${enter.side}`)
}
if (leave && (both || !targetSide)) {
const again = leaveBySide(points, p)
if (again) {
points = again.points
sourceSide = again.side
sideTaken.add(`${p.source.id}:${again.side}`)
}
}
}
for (const id of p.edgeIds)
out.set(id, {
points,
junctions,
...(sourceSide ? { sourceSide } : {}),
...(targetSide ? { targetSide } : {})
})
}
return out
}
/**
* Lane for an edge whose way down its target's column is blocked by a node in
* the rows between its ends: the nearest x, to the left or to the right, that
* clears every node in those rows — not only the first one in the way.
* undefined when the column is clear.
*/
export function detourLane(
source: RouteNode,
target: RouteNode,
nodes: RouteNode[],
pad: number
): number | undefined {
if (target.top <= source.top) return undefined
const between = nodes.filter(
(n) => n !== source && n !== target && n.top > source.top && n.top < target.top
)
const hits = (x: number) => between.filter((n) => Math.abs(x - n.cx) < n.halfW + 8)
if (hits(target.cx).length === 0) return undefined
// Step past whatever the lane still hits until nothing does; each step moves
// strictly outward, so it ends.
const clearToward = (dir: -1 | 1) => {
let x = target.cx
for (let h = hits(x); h.length > 0; h = hits(x))
x =
dir < 0
? Math.min(...h.map((n) => n.cx - n.halfW - pad))
: Math.max(...h.map((n) => n.cx + n.halfW + pad))
return x
}
const left = clearToward(-1)
const right = clearToward(1)
const detour = (x: number) => Math.abs(x - source.cx) + Math.abs(x - target.cx)
return detour(left) <= detour(right) ? left : right
}
/** Track per net, 0 the highest: the order of the lines from top to bottom is
* the one with the fewest crossings found, then each line takes the highest
* track below every earlier line it overlaps. */
function assignTracks(list: Net[]): number[] {
const spans = (n: Net, x: number) => x > n.from + 0.5 && x < n.to - 0.5
const overlaps = (a: Net, b: Net) => a.from < b.to + PORT_GAP && b.from < a.to + PORT_GAP
// Crossings when a sits above b: a's lines leaving the gap pass through b,
// and b's lines coming into it pass through a.
const cost = list.map((a) =>
list.map((b) =>
a === b
? 0
: a.bottomPins.filter((x) => spans(b, x)).length +
b.topPins.filter((x) => spans(a, x)).length
)
)
// Start from the order each pair prefers on its own, then move one line at a
// time to wherever it removes the most crossings. Moving a line only changes
// the pairs it is part of, so every position is scored in one sweep: linear
// per line, quadratic per pass — a graph of hundreds of lines routes at once.
let order = list
.map((_, i) => i)
.sort((i, j) => cost[i][j] - cost[j][i] || list[i].from - list[j].from)
const MAX_PASSES = 8
for (let pass = 0, improved = true; improved && pass < MAX_PASSES; pass++) {
improved = false
for (let k = 0; k < order.length; k++) {
const v = order[k]
const rest = order.filter((_, i) => i !== k)
// Crossings involving v with v first: v above every other line.
let c = 0
for (const j of rest) c += cost[v][j]
let best = 0
let bestCost = c
let current = c
for (let pos = 0; pos < rest.length; pos++) {
// Moving v below rest[pos] swaps that one pair.
c += cost[rest[pos]][v] - cost[v][rest[pos]]
if (pos + 1 === k) current = c
if (c < bestCost) {
best = pos + 1
bestCost = c
}
}
if (k === 0) current = rest.reduce((a, j) => a + cost[v][j], 0)
if (bestCost < current) {
order = [...rest.slice(0, best), v, ...rest.slice(best)]
improved = true
}
}
}
const track: number[] = list.map(() => -1)
order.forEach((i, k) => {
let t = 0
for (const j of order.slice(0, k)) if (overlaps(list[i], list[j])) t = Math.max(t, track[j] + 1)
track[i] = t
})
return track
}
/** Drop repeated points and the middle of three on one line, so no turn of zero
* width is drawn. */
function dropCollinear(points: Array<[number, number]>): Array<[number, number]> {
const out: Array<[number, number]> = []
for (const p of points) {
const prev = out[out.length - 1]
if (prev && prev[0] === p[0] && prev[1] === p[1]) continue
const prev2 = out[out.length - 2]
if (
prev &&
prev2 &&
((prev2[0] === prev[0] && prev[0] === p[0]) || (prev2[1] === prev[1] && prev[1] === p[1]))
) {
out[out.length - 1] = p
continue
}
out.push(p)
}
return out
}
/** SVG path through orthogonal points, each corner rounded by up to `radius`
* but those in `sharp`. */
export function roundedPath(
points: Array<[number, number]>,
radius: number,
/** Corners drawn sharp: junctions, where other lines meet this one exactly. */
sharp: Array<[number, 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 isSharp = sharp.some(([sx, sy]) => Math.abs(sx - cx) < 0.5 && Math.abs(sy - cy) < 0.5)
const r = isSharp ? 0 : 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.
export const LAYER_GAP = NODE.gap.vertical + 16
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
@@ -244,11 +244,6 @@ function layoutComponent(
return out
}
/** Height reserved for every row of the layout: no node renders taller. */
export function layoutRowHeight(extraRowHeight = 0): number {
return NODE_HEIGHT + extraRowHeight
}
// Top-down layered layout (same orientation as the flow editor — see
// compoundLayout.ts): producers above → assets in the middle → consumers
// below.
@@ -270,7 +265,7 @@ export function layoutAssetGraph(
anchorId?: string,
extraRowHeight = 0
): Map<string, Positioned> {
const rowH = layoutRowHeight(extraRowHeight)
const rowH = NODE_HEIGHT + extraRowHeight
const layerH = rowH + LAYER_GAP
const byId = new Map<string, Positioned>()
if (graph.nodes.length === 0) return byId