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) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 11:12:27 +02:00
co-authored by Claude Opus 5.5
parent 1bf5333a90
commit 8167cadbdf
5 changed files with 759 additions and 274 deletions
@@ -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
)
@@ -1,5 +1,5 @@
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { BaseEdge, getBezierPath, useInternalNode, type EdgeProps } from '@xyflow/svelte'
import { polylineMidpoint, roundedPath, type EdgeRoute } from './assetGraphEdgeRouting'
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
@@ -7,6 +7,8 @@
const CORNER = 10
let {
source,
target,
sourceX,
sourceY,
sourcePosition,
@@ -29,34 +31,57 @@
// 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 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]
]
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)
@@ -130,7 +155,8 @@
)
let edgePath = $derived.by(() => {
if (points) return roundedPath(points, CORNER)
// 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({
@@ -154,6 +180,9 @@
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,73 +1,188 @@
import { describe, it, expect } from 'vitest'
import { routeAssetGraphEdges, roundedPath, type RouteNode } from './assetGraphEdgeRouting'
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 })
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', () => {
// 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('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('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('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('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('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('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(
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,
[
{ id: 'e1', source: 's1', target: 't', laneX: 150 },
{ id: 'e2', source: 's2', target: 't', laneX: 150 }
{ ...edge('s1', 't1'), laneX: 150 },
{ ...edge('s2', 't2'), laneX: 150 }
],
GAP
)
expect(laned.get('e1')!.lane!.x).not.toBe(laned.get('e2')!.lane!.x)
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), node('u', 300, 200)],
[
{ id: 'read', source: 's', target: 't' },
{ id: 'trigger', source: 's', target: 't' },
{ id: 'other', source: 's', target: 'u' }
],
[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('read'))
expect(r.get('other')!.sourceOffset).not.toBe(r.get('read')!.sourceOffset)
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)],
[{ id: 'back', source: 'down', target: 'up' }],
[edge('down', 'up')],
GAP
)
expect(r.has('back')).toBe(false)
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()
})
})
@@ -1,18 +1,38 @@
// 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:
// — vertical runs joined by horizontal runs with rounded corners — by these
// rules, in order:
//
// - 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.
// 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.
@@ -24,6 +44,8 @@ export type RouteNode = {
/** 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 = {
@@ -32,17 +54,20 @@ export type RouteEdge = {
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
}
/** Offsets and heights the edge component combines with its handle coordinates. */
/** 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 = {
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 }
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
@@ -51,18 +76,25 @@ 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 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'
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[],
@@ -71,176 +103,490 @@ export function routeAssetGraphEdges(
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>()
// 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 && !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'))
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 {
segments.push(seg(e.id, layerOf(s) + 1, sx, lx, 'upper'))
segments.push(seg(e.id, layerOf(t), lx, tx, 'lower'))
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 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])
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) {
const tracks = assignTracks(list)
const count = Math.max(...tracks.values()) + 1
// 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
for (const sg of list) heights.set(`${sg.edgeId}:${sg.which}`, first + tracks.get(sg)! * step)
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
)
}
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`)! } } : {})
})
// 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 e of edges) {
const r = routeOf.get(pairKey(e))
if (r) out.set(e.id, r)
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
}
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
/**
* 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
}
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)
/** 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
)
)
const total = (order: number[]) => {
let sum = 0
for (let i = 0; i < order.length; i++)
for (let j = i + 1; j < order.length; j++) sum += cost[order[i]][order[j]]
return sum
}
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)
// Start from the order each pair prefers on its own, then move one line at a
// time to wherever it removes crossings, until no move helps.
let order = list
.map((_, i) => i)
.sort((i, j) => cost[i][j] - cost[j][i] || list[i].from - list[j].from)
for (let improved = true; improved; ) {
improved = false
for (let k = 0; k < order.length; k++) {
const rest = order.filter((_, i) => i !== k)
let best = order
let bestCost = total(order)
for (let pos = 0; pos <= rest.length; pos++) {
const tried = [...rest.slice(0, pos), order[k], ...rest.slice(pos)]
const c = total(tried)
if (c < bestCost) {
best = tried
bestCost = c
}
}
if (best !== order) {
order = best
improved = true
}
}
}
// 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) {
const track: number[] = list.map(() => -1)
order.forEach((i, k) => {
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)
}
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
}
/** SVG path through orthogonal points, each corner rounded by up to `radius`. */
export function roundedPath(points: Array<[number, number]>, radius: number): string {
/** 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]
)
@@ -252,7 +598,8 @@ export function roundedPath(points: Array<[number, number]>, radius: number): st
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 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
@@ -244,6 +244,11 @@ 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.
@@ -265,7 +270,7 @@ export function layoutAssetGraph(
anchorId?: string,
extraRowHeight = 0
): Map<string, Positioned> {
const rowH = NODE_HEIGHT + extraRowHeight
const rowH = layoutRowHeight(extraRowHeight)
const layerH = rowH + LAYER_GAP
const byId = new Map<string, Positioned>()
if (graph.nodes.length === 0) return byId