mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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:
co-authored by
Claude Opus 5.5
parent
1bf5333a90
commit
8167cadbdf
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user