mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 00:03:19 +00:00
feat: route pipeline graph edges orthogonally with spread ports, separate tracks and lanes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
69e8445b2c
commit
1bf5333a90
@@ -23,11 +23,13 @@
|
||||
import PanToNode from './PanToNode.svelte'
|
||||
import InitialFitView from './InitialFitView.svelte'
|
||||
import {
|
||||
LAYER_GAP,
|
||||
layoutAssetGraph,
|
||||
PIPELINE_NODE_EXTRA_ROW,
|
||||
PIPELINE_NODE_HEADER,
|
||||
PIPELINE_NODE_HEADER_CARD_PAD
|
||||
} from './assetGraphLayout'
|
||||
import { routeAssetGraphEdges } from './assetGraphEdgeRouting'
|
||||
import {
|
||||
assetsOnlyView,
|
||||
upstreamDeletion,
|
||||
@@ -1889,6 +1891,21 @@
|
||||
return lane
|
||||
}
|
||||
|
||||
let edgeRoutes = $derived(
|
||||
routeAssetGraphEdges(
|
||||
nodeCenters.map((n) => ({ id: n.id, cx: n.cx, top: n.cy, halfW: n.halfW })),
|
||||
view.edges
|
||||
.filter((e) => e.kind !== 'add-anchor')
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
laneX: detourForEdge(e.source, e.target)
|
||||
})),
|
||||
LAYER_GAP
|
||||
)
|
||||
)
|
||||
|
||||
let flowEdges = $derived.by(() =>
|
||||
view.edges
|
||||
// Anchor edges are layout-only.
|
||||
@@ -2035,7 +2052,7 @@
|
||||
target: e.target,
|
||||
type: 'asset',
|
||||
data: {
|
||||
detourX: detourForEdge(e.source, e.target),
|
||||
route: edgeRoutes.get(e.id),
|
||||
// Data-test badge on the producer→asset write-edge. The
|
||||
// producer's last-run status (the script fails if any test
|
||||
// fails) tints it green/red; neutral until it has run.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
|
||||
import { polylineMidpoint, roundedPath, type EdgeRoute } from './assetGraphEdgeRouting'
|
||||
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
|
||||
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
|
||||
|
||||
const CORNER = 10
|
||||
|
||||
let {
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -24,12 +25,41 @@
|
||||
let testsStatus = $derived(
|
||||
(data as { testsRunStatus?: 'running' | 'success' | 'failure' } | undefined)?.testsRunStatus
|
||||
)
|
||||
// Badge position on the link. A detoured edge runs its vertical segment in
|
||||
// the gutter lane at `detourX`, so anchor the badge there (not on the
|
||||
// straight-line midpoint, which would float off the routed path).
|
||||
let detourX = $derived((data as { detourX?: number } | undefined)?.detourX)
|
||||
let labelX = $derived(detourX ?? (sourceX + targetX) / 2)
|
||||
let labelY = $derived((sourceY + targetY) / 2)
|
||||
// The route the canvas computed for this edge (see assetGraphEdgeRouting):
|
||||
// spread ports, the height of each horizontal run and, for an edge that
|
||||
// skips layers, the lane beside the nodes in between.
|
||||
let route = $derived((data as { route?: EdgeRoute } | undefined)?.route)
|
||||
let points = $derived.by((): Array<[number, number]> | undefined => {
|
||||
if (!route) return undefined
|
||||
const sx = sourceX + route.sourceOffset
|
||||
const tx = targetX + route.targetOffset
|
||||
// The layout reserves each row's height, but a node can render shorter or
|
||||
// taller; keep every run clear of the edge's own endpoints.
|
||||
const clamp = (y: number) =>
|
||||
Math.min(Math.max(y, sourceY + 8), Math.max(sourceY + 8, targetY - 8))
|
||||
if (route.lane) {
|
||||
const y1 = Math.max(route.lane.y, sourceY + 8)
|
||||
const y2 = Math.min(Math.max(route.y, y1), targetY - 8)
|
||||
return [
|
||||
[sx, sourceY],
|
||||
[sx, y1],
|
||||
[route.lane.x, y1],
|
||||
[route.lane.x, y2],
|
||||
[tx, y2],
|
||||
[tx, targetY]
|
||||
]
|
||||
}
|
||||
const y = clamp(route.y)
|
||||
return [
|
||||
[sx, sourceY],
|
||||
[sx, y],
|
||||
[tx, y],
|
||||
[tx, targetY]
|
||||
]
|
||||
})
|
||||
let mid = $derived(points ? polylineMidpoint(points) : undefined)
|
||||
let labelX = $derived(mid?.[0] ?? (sourceX + targetX) / 2)
|
||||
let labelY = $derived(mid?.[1] ?? (sourceY + targetY) / 2)
|
||||
|
||||
function fmtTest(t: DataTest): string {
|
||||
switch (t.type) {
|
||||
@@ -99,79 +129,20 @@
|
||||
: ''
|
||||
)
|
||||
|
||||
// An edge that skips at least one full layer (source-bottom → target-top
|
||||
// gap larger than gap + node row) while staying near-vertical runs
|
||||
// straight under the nodes in between — the child-and-grandchild-of-the-
|
||||
// same-node case, where the tidy-tree layout puts all three on one
|
||||
// column. Detour those through the gutter beside the column.
|
||||
const SKIP_DY = NODE.gap.vertical * 2 + PIPELINE_NODE_HEIGHT
|
||||
const NEAR_VERTICAL_DX = NODE.width / 4
|
||||
// Lane offset: half a node plus a margin that stays inside the
|
||||
// inter-column gutter (gap.horizontal / 2).
|
||||
const LANE = NODE.width / 2 + NODE.gap.horizontal / 2
|
||||
const CORNER = 14
|
||||
|
||||
// Rounded-orthogonal detour: drop out of the source, swing into the
|
||||
// gutter lane at `midX`, run it down past the intermediate rows, swing
|
||||
// back into the target.
|
||||
function gutterPath(midX: number): string {
|
||||
const yTop = sourceY + 24
|
||||
const yBot = targetY - 24
|
||||
const outDir = midX > sourceX ? 1 : -1
|
||||
const inDir = targetX > midX ? 1 : -1
|
||||
return [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
`L ${sourceX} ${yTop - CORNER}`,
|
||||
`Q ${sourceX} ${yTop} ${sourceX + outDir * CORNER} ${yTop}`,
|
||||
`L ${midX - outDir * CORNER} ${yTop}`,
|
||||
`Q ${midX} ${yTop} ${midX} ${yTop + CORNER}`,
|
||||
`L ${midX} ${yBot - CORNER}`,
|
||||
`Q ${midX} ${yBot} ${midX + inDir * CORNER} ${yBot}`,
|
||||
`L ${targetX - inDir * CORNER} ${yBot}`,
|
||||
`Q ${targetX} ${yBot} ${targetX} ${yBot + CORNER}`,
|
||||
`L ${targetX} ${targetY}`
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
// Long edges bend out of the source within the layer gap and descend in
|
||||
// the *target* column. The flow editor's pattern (descend at the source,
|
||||
// bend at the bottom) assumed the source column below was free — in the
|
||||
// banded tree layout it's where the source's own children live, so the
|
||||
// straight segment ran under them (e.g. fx_rates → daily_revenue under
|
||||
// fx_rates' other subtree). Long edges only target joins here (a single-
|
||||
// parent child is always exactly one layer below its parent), and the
|
||||
// space above a join is the seam between its parents' bands — clear by
|
||||
// construction.
|
||||
let edgePath = $derived.by(() => {
|
||||
const dy = targetY - sourceY
|
||||
// Obstacle-aware detour: the canvas detected this edge's straight run
|
||||
// would pass over an unrelated node and chose a clear lane. Route there.
|
||||
const detourX = (data as { detourX?: number } | undefined)?.detourX
|
||||
if (detourX != undefined) {
|
||||
return gutterPath(detourX)
|
||||
}
|
||||
if (dy > SKIP_DY && Math.abs(targetX - sourceX) < NEAR_VERTICAL_DX) {
|
||||
// Near-vertical same-column skip: lane just beside the column, past
|
||||
// both endpoints so the horizontal runs never degenerate.
|
||||
const side = targetX >= sourceX ? 1 : -1
|
||||
return gutterPath(
|
||||
side > 0 ? Math.max(sourceX, targetX) + LANE : Math.min(sourceX, targetX) - LANE
|
||||
)
|
||||
}
|
||||
const bendY = sourceY + Math.min(100, NODE.gap.vertical)
|
||||
const long = dy > 100
|
||||
if (points) return roundedPath(points, CORNER)
|
||||
// No route: an edge pointing up (a cycle's feedback edge), which the
|
||||
// layered routing has no gap for.
|
||||
const [bezier] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY: long ? bendY : targetY,
|
||||
targetY,
|
||||
targetPosition,
|
||||
curvature: 0.25
|
||||
})
|
||||
// The bezier ends at (targetX, bendY); continue straight down the
|
||||
// target column into the node.
|
||||
return long ? `${bezier} L${targetX},${targetY}` : bezier
|
||||
return bezier
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { routeAssetGraphEdges, roundedPath, type RouteNode } from './assetGraphEdgeRouting'
|
||||
|
||||
const GAP = 80
|
||||
const node = (id: string, cx: number, top: number): RouteNode => ({ id, cx, top, halfW: 100 })
|
||||
|
||||
describe('routeAssetGraphEdges', () => {
|
||||
// Four sources in one row feeding one target below: the screenshot shape
|
||||
// where every edge used to land on the same point and share one height.
|
||||
const sources = [node('a', 0, 0), node('b', 300, 0), node('c', 600, 0), node('d', 900, 0)]
|
||||
const target = node('t', 450, 200)
|
||||
const fanIn = ['a', 'b', 'c', 'd'].map((s) => ({ id: `${s}-t`, source: s, target: 't' }))
|
||||
const routes = routeAssetGraphEdges([...sources, target], fanIn, GAP)
|
||||
|
||||
it('spreads a fan-in across the target, in the order of the sources', () => {
|
||||
const offsets = fanIn.map((e) => routes.get(e.id)!.targetOffset)
|
||||
expect(new Set(offsets).size).toBe(4)
|
||||
expect([...offsets].sort((x, y) => x - y)).toEqual(offsets)
|
||||
})
|
||||
|
||||
it('keeps every run of a fan-in inside the gap above the target', () => {
|
||||
for (const e of fanIn) {
|
||||
const y = routes.get(e.id)!.y
|
||||
expect(y).toBeGreaterThan(200 - GAP)
|
||||
expect(y).toBeLessThan(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('stacks overlapping runs so none crosses another edge', () => {
|
||||
// Coming from the left, the farther source runs lower: its run then passes
|
||||
// under where the nearer source comes down, instead of through it.
|
||||
expect(routes.get('a-t')!.y).toBeGreaterThan(routes.get('b-t')!.y)
|
||||
// Mirrored on the right.
|
||||
expect(routes.get('d-t')!.y).toBeGreaterThan(routes.get('c-t')!.y)
|
||||
})
|
||||
|
||||
it('offsets edges that share a lane', () => {
|
||||
const nodes = [node('s1', 0, 0), node('s2', 0, 0), node('mid', 0, 200), node('t', 0, 400)]
|
||||
const laned = routeAssetGraphEdges(
|
||||
nodes,
|
||||
[
|
||||
{ id: 'e1', source: 's1', target: 't', laneX: 150 },
|
||||
{ id: 'e2', source: 's2', target: 't', laneX: 150 }
|
||||
],
|
||||
GAP
|
||||
)
|
||||
expect(laned.get('e1')!.lane!.x).not.toBe(laned.get('e2')!.lane!.x)
|
||||
})
|
||||
|
||||
it('gives edges joining the same two nodes one route', () => {
|
||||
const r = routeAssetGraphEdges(
|
||||
[node('s', 0, 0), node('t', 0, 200), node('u', 300, 200)],
|
||||
[
|
||||
{ id: 'read', source: 's', target: 't' },
|
||||
{ id: 'trigger', source: 's', target: 't' },
|
||||
{ id: 'other', source: 's', target: 'u' }
|
||||
],
|
||||
GAP
|
||||
)
|
||||
expect(r.get('trigger')).toEqual(r.get('read'))
|
||||
expect(r.get('other')!.sourceOffset).not.toBe(r.get('read')!.sourceOffset)
|
||||
})
|
||||
|
||||
it('leaves an upward edge unrouted', () => {
|
||||
const r = routeAssetGraphEdges(
|
||||
[node('up', 0, 0), node('down', 0, 200)],
|
||||
[{ id: 'back', source: 'down', target: 'up' }],
|
||||
GAP
|
||||
)
|
||||
expect(r.has('back')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roundedPath', () => {
|
||||
it('rounds each corner and ends on the last point', () => {
|
||||
const d = roundedPath(
|
||||
[
|
||||
[0, 0],
|
||||
[0, 50],
|
||||
[100, 50],
|
||||
[100, 100]
|
||||
],
|
||||
10
|
||||
)
|
||||
expect(d.startsWith('M 0 0')).toBe(true)
|
||||
expect(d.match(/Q/g)).toHaveLength(2)
|
||||
expect(d.endsWith('L 100 100')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
// Orthogonal edge routing for the asset graph. Every edge is drawn the same way
|
||||
// — vertical runs joined by horizontal runs with rounded corners — and three
|
||||
// things keep edges apart where they would otherwise be drawn on top of each
|
||||
// other:
|
||||
//
|
||||
// - Ports: the edges leaving (or entering) one node are spread along its bottom
|
||||
// (top) edge, ordered by where their other end lies, instead of all meeting
|
||||
// at the node's center.
|
||||
// - Tracks: the horizontal runs sharing the gap between two layers get distinct
|
||||
// heights. Their order follows the channel-routing constraint that removes
|
||||
// avoidable crossings: a run must sit above any run that drops out of the gap
|
||||
// at an x it spans, and below any run that comes down into the gap at an x it
|
||||
// spans.
|
||||
// - Lanes: edges that skip layers come down a lane beside the nodes in between
|
||||
// (chosen by the caller); edges sharing a lane are offset side by side.
|
||||
//
|
||||
// Pure: the canvas feeds node boxes and edges, the edge component draws
|
||||
// `roundedPath` through the points it gets back.
|
||||
|
||||
export type RouteNode = {
|
||||
id: string
|
||||
/** Horizontal center. */
|
||||
cx: number
|
||||
/** Top edge — nodes of one layer share it. */
|
||||
top: number
|
||||
halfW: number
|
||||
}
|
||||
|
||||
export type RouteEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
/** Lane x for an edge that must go around the nodes between its endpoints. */
|
||||
laneX?: number
|
||||
}
|
||||
|
||||
/** Offsets and heights the edge component combines with its handle coordinates. */
|
||||
export type EdgeRoute = {
|
||||
sourceOffset: number
|
||||
targetOffset: number
|
||||
/** Height of the edge's run into the target's column (absolute): in the gap
|
||||
* below the source, or for a laned edge in the gap above the target. */
|
||||
y: number
|
||||
/** For a laned edge: the lane, and the height of the run in the gap below the source. */
|
||||
lane?: { x: number; y: number }
|
||||
}
|
||||
|
||||
const PORT_GAP = 14
|
||||
const LANE_GAP = 10
|
||||
const MAX_TRACK_GAP = 10
|
||||
// Clearance kept between a gap's runs and the node rows bounding it.
|
||||
const GAP_MARGIN_TOP = 18
|
||||
const GAP_MARGIN_BOTTOM = 14
|
||||
|
||||
type Segment = {
|
||||
edgeId: string
|
||||
gap: number
|
||||
from: number
|
||||
to: number
|
||||
/** x where the run comes down into the gap, and where it leaves it downward. */
|
||||
topPin: number
|
||||
bottomPin: number
|
||||
which: 'upper' | 'lower'
|
||||
}
|
||||
|
||||
export function routeAssetGraphEdges(
|
||||
nodes: RouteNode[],
|
||||
edges: RouteEdge[],
|
||||
layerGap: number
|
||||
): Map<string, EdgeRoute> {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const layerTops = [...new Set(nodes.map((n) => n.top))].sort((a, b) => a - b)
|
||||
const layerOf = (n: RouteNode) => layerTops.indexOf(n.top)
|
||||
// Edges joining the same two nodes (a read and the trigger it derives) share
|
||||
// one route and are drawn over each other; one of them stands for the pair.
|
||||
const pairKey = (e: RouteEdge) => `${e.source}\u0000${e.target}`
|
||||
const representative = new Map<string, RouteEdge>()
|
||||
for (const e of edges) {
|
||||
const s = byId.get(e.source)
|
||||
const t = byId.get(e.target)
|
||||
if (s && t && t.top > s.top && !representative.has(pairKey(e))) representative.set(pairKey(e), e)
|
||||
}
|
||||
const routable = [...representative.values()]
|
||||
|
||||
// Lanes: edges whose lanes land within a few pixels of each other share a
|
||||
// corridor; spread them side by side, ordered by their target's x so they
|
||||
// leave the corridor without crossing.
|
||||
const laneX = new Map<string, number>()
|
||||
const laned = routable.filter((e) => e.laneX != undefined).sort((a, b) => a.laneX! - b.laneX!)
|
||||
for (let i = 0; i < laned.length; ) {
|
||||
let j = i + 1
|
||||
while (j < laned.length && laned[j].laneX! - laned[i].laneX! < LANE_GAP) j++
|
||||
const group = laned.slice(i, j).sort((a, b) => byId.get(a.target)!.cx - byId.get(b.target)!.cx)
|
||||
group.forEach((e, k) => laneX.set(e.id, e.laneX! + (k - (group.length - 1) / 2) * LANE_GAP))
|
||||
i = j
|
||||
}
|
||||
|
||||
// Ports: where each edge's other end lies, seen from this node.
|
||||
const farX = (e: RouteEdge, end: 'source' | 'target') =>
|
||||
laneX.get(e.id) ?? byId.get(end === 'source' ? e.target : e.source)!.cx
|
||||
const sourceOffset = new Map<string, number>()
|
||||
const targetOffset = new Map<string, number>()
|
||||
function spread(node: RouteNode, list: RouteEdge[], end: 'source' | 'target') {
|
||||
const sorted = [...list].sort((a, b) => farX(a, end) - farX(b, end))
|
||||
const gap = Math.min(PORT_GAP, (node.halfW * 1.4) / Math.max(1, sorted.length - 1))
|
||||
sorted.forEach((e, k) =>
|
||||
(end === 'source' ? sourceOffset : targetOffset).set(
|
||||
e.id,
|
||||
(k - (sorted.length - 1) / 2) * gap
|
||||
)
|
||||
)
|
||||
}
|
||||
for (const n of nodes) {
|
||||
spread(
|
||||
n,
|
||||
routable.filter((e) => e.source === n.id),
|
||||
'source'
|
||||
)
|
||||
spread(
|
||||
n,
|
||||
routable.filter((e) => e.target === n.id),
|
||||
'target'
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal runs, keyed by the gap they sit in (gap g lies above layer g).
|
||||
const segments: Segment[] = []
|
||||
for (const e of routable) {
|
||||
const s = byId.get(e.source)!
|
||||
const t = byId.get(e.target)!
|
||||
const sx = s.cx + sourceOffset.get(e.id)!
|
||||
const tx = t.cx + targetOffset.get(e.id)!
|
||||
const lx = laneX.get(e.id)
|
||||
if (lx == undefined) {
|
||||
// Turn in the gap below the source, then down the target's column — the
|
||||
// column the caller checked clear of the nodes in between.
|
||||
segments.push(seg(e.id, layerOf(s) + 1, sx, tx, 'lower'))
|
||||
} else {
|
||||
segments.push(seg(e.id, layerOf(s) + 1, sx, lx, 'upper'))
|
||||
segments.push(seg(e.id, layerOf(t), lx, tx, 'lower'))
|
||||
}
|
||||
}
|
||||
|
||||
const heights = new Map<string, number>()
|
||||
const byGap = new Map<number, Segment[]>()
|
||||
for (const sg of segments) byGap.set(sg.gap, [...(byGap.get(sg.gap) ?? []), sg])
|
||||
for (const [gap, list] of byGap) {
|
||||
const tracks = assignTracks(list)
|
||||
const count = Math.max(...tracks.values()) + 1
|
||||
const bandTop = layerTops[gap] - layerGap + GAP_MARGIN_TOP
|
||||
const bandBottom = layerTops[gap] - GAP_MARGIN_BOTTOM
|
||||
const step = Math.min(MAX_TRACK_GAP, (bandBottom - bandTop) / Math.max(1, count))
|
||||
// Centered in the band: a lone run sits midway between the rows.
|
||||
const first = (bandTop + bandBottom) / 2 - ((count - 1) * step) / 2
|
||||
for (const sg of list) heights.set(`${sg.edgeId}:${sg.which}`, first + tracks.get(sg)! * step)
|
||||
}
|
||||
|
||||
const routeOf = new Map<string, EdgeRoute>()
|
||||
for (const e of routable) {
|
||||
const lx = laneX.get(e.id)
|
||||
routeOf.set(pairKey(e), {
|
||||
sourceOffset: sourceOffset.get(e.id)!,
|
||||
targetOffset: targetOffset.get(e.id)!,
|
||||
y: heights.get(`${e.id}:lower`)!,
|
||||
...(lx != undefined ? { lane: { x: lx, y: heights.get(`${e.id}:upper`)! } } : {})
|
||||
})
|
||||
}
|
||||
const out = new Map<string, EdgeRoute>()
|
||||
for (const e of edges) {
|
||||
const r = routeOf.get(pairKey(e))
|
||||
if (r) out.set(e.id, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function seg(
|
||||
edgeId: string,
|
||||
gap: number,
|
||||
topPin: number,
|
||||
bottomPin: number,
|
||||
which: Segment['which']
|
||||
): Segment {
|
||||
return {
|
||||
edgeId,
|
||||
gap,
|
||||
from: Math.min(topPin, bottomPin),
|
||||
to: Math.max(topPin, bottomPin),
|
||||
topPin,
|
||||
bottomPin,
|
||||
which
|
||||
}
|
||||
}
|
||||
|
||||
const spans = (sg: Segment, x: number) => x > sg.from + 0.5 && x < sg.to - 0.5
|
||||
const overlaps = (a: Segment, b: Segment) => a.from < b.to + PORT_GAP && b.from < a.to + PORT_GAP
|
||||
|
||||
/** Track per segment, 0 the highest. */
|
||||
function assignTracks(list: Segment[]): Map<Segment, number> {
|
||||
// below.get(a) = runs that must sit below a.
|
||||
const below = new Map<Segment, Set<Segment>>(list.map((s) => [s, new Set()]))
|
||||
const indeg = new Map<Segment, number>(list.map((s) => [s, 0]))
|
||||
const addAbove = (upper: Segment, lower: Segment) => {
|
||||
if (upper === lower || below.get(upper)!.has(lower)) return
|
||||
below.get(upper)!.add(lower)
|
||||
indeg.set(lower, indeg.get(lower)! + 1)
|
||||
}
|
||||
for (const a of list) {
|
||||
for (const b of list) {
|
||||
if (a === b) continue
|
||||
// a comes down into the gap where b runs: a's run must be above b's.
|
||||
if (spans(b, a.topPin)) addAbove(a, b)
|
||||
// a leaves the gap downward where b runs: a's run must be below b's.
|
||||
if (spans(b, a.bottomPin)) addAbove(b, a)
|
||||
}
|
||||
}
|
||||
// Topological order; a cycle (two runs each needing to be above the other)
|
||||
// cannot be crossing-free, so the least constrained run goes first.
|
||||
const order: Segment[] = []
|
||||
const done = new Set<Segment>()
|
||||
while (order.length < list.length) {
|
||||
let next = list.find((s) => !done.has(s) && indeg.get(s) === 0)
|
||||
if (!next) {
|
||||
for (const s of list)
|
||||
if (!done.has(s) && (!next || indeg.get(s)! < indeg.get(next)!)) next = s
|
||||
}
|
||||
done.add(next!)
|
||||
order.push(next!)
|
||||
for (const l of below.get(next!)!) indeg.set(l, indeg.get(l)! - 1)
|
||||
}
|
||||
const track = new Map<Segment, number>()
|
||||
for (const s of order) {
|
||||
let t = 0
|
||||
for (const [upper, lowers] of below) {
|
||||
if (lowers.has(s) && track.has(upper)) t = Math.max(t, track.get(upper)! + 1)
|
||||
}
|
||||
while ([...track].some(([o, ot]) => ot === t && overlaps(o, s))) t++
|
||||
track.set(s, t)
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
/** SVG path through orthogonal points, each corner rounded by up to `radius`. */
|
||||
export function roundedPath(points: Array<[number, number]>, radius: number): string {
|
||||
const pts = points.filter(
|
||||
(p, i) => i === 0 || p[0] !== points[i - 1][0] || p[1] !== points[i - 1][1]
|
||||
)
|
||||
if (pts.length < 2) return ''
|
||||
let d = `M ${pts[0][0]} ${pts[0][1]}`
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const [px, py] = pts[i - 1]
|
||||
const [cx, cy] = pts[i]
|
||||
const [nx, ny] = pts[i + 1]
|
||||
const inLen = Math.hypot(cx - px, cy - py)
|
||||
const outLen = Math.hypot(nx - cx, ny - cy)
|
||||
const r = Math.min(radius, inLen / 2, outLen / 2)
|
||||
const ax = cx - ((cx - px) / inLen) * r
|
||||
const ay = cy - ((cy - py) / inLen) * r
|
||||
const bx = cx + ((nx - cx) / outLen) * r
|
||||
const by = cy + ((ny - cy) / outLen) * r
|
||||
d += ` L ${ax} ${ay} Q ${cx} ${cy} ${bx} ${by}`
|
||||
}
|
||||
const last = pts[pts.length - 1]
|
||||
return `${d} L ${last[0]} ${last[1]}`
|
||||
}
|
||||
|
||||
/** The point halfway along a polyline, where an edge's badges sit. */
|
||||
export function polylineMidpoint(points: Array<[number, number]>): [number, number] {
|
||||
const lens = points.slice(1).map((p, i) => Math.hypot(p[0] - points[i][0], p[1] - points[i][1]))
|
||||
let half = lens.reduce((a, b) => a + b, 0) / 2
|
||||
for (let i = 0; i < lens.length; i++) {
|
||||
if (half <= lens[i] && lens[i] > 0) {
|
||||
const [ax, ay] = points[i]
|
||||
const [bx, by] = points[i + 1]
|
||||
return [ax + ((bx - ax) * half) / lens[i], ay + ((by - ay) * half) / lens[i]]
|
||||
}
|
||||
half -= lens[i]
|
||||
}
|
||||
return points[points.length - 1]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const NODE_HEIGHT = NODE.height + 30
|
||||
* the `NODE_HEIGHT` row the layout reserves. */
|
||||
export const PIPELINE_NODE_HEIGHT = 48
|
||||
// A step more than the flow editor's gap: the + on a node's bottom edge sits in it.
|
||||
const LAYER_GAP = NODE.gap.vertical + 16
|
||||
export const LAYER_GAP = NODE.gap.vertical + 16
|
||||
/** Height of the "Add data source" pill the anchor draws, and its gap to the
|
||||
* graph: tighter than LAYER_GAP, since no edge runs through it. */
|
||||
const ANCHOR_HEIGHT = 32
|
||||
|
||||
Reference in New Issue
Block a user