mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat: band-reserving tidy-tree asset graph layout with join breakpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { layoutAssetGraph } from './assetGraphLayout'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
|
||||
const NODE_WIDTH = NODE.width
|
||||
|
||||
const n = (id: string) => ({ id, data: {} as any })
|
||||
const e = (source: string, target: string) => ({ source, target })
|
||||
|
||||
describe('layoutAssetGraph (tidy-tree with join breaks)', () => {
|
||||
it('lays a chain out vertically on one column', () => {
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('a'), n('b'), n('c')],
|
||||
edges: [e('a', 'b'), e('b', 'c')]
|
||||
})
|
||||
expect(pos.get('a')!.x).toBe(pos.get('b')!.x)
|
||||
expect(pos.get('b')!.x).toBe(pos.get('c')!.x)
|
||||
expect(pos.get('a')!.y).toBeLessThan(pos.get('b')!.y)
|
||||
expect(pos.get('b')!.y).toBeLessThan(pos.get('c')!.y)
|
||||
})
|
||||
|
||||
it('centers a parent over side-by-side children', () => {
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('p'), n('l'), n('r')],
|
||||
edges: [e('p', 'l'), e('p', 'r')]
|
||||
})
|
||||
const p = pos.get('p')!
|
||||
const l = pos.get('l')!
|
||||
const r = pos.get('r')!
|
||||
expect(l.y).toBe(r.y)
|
||||
expect(r.x - l.x).toBeGreaterThanOrEqual(NODE_WIDTH)
|
||||
expect(p.x).toBeCloseTo((l.x + r.x) / 2, 5)
|
||||
})
|
||||
|
||||
it('keeps subtrees of different parents in disjoint horizontal bands', () => {
|
||||
// Two branches under one root; the left branch fans out into three
|
||||
// leaves. With per-band reservation, every node of the left branch
|
||||
// stays strictly left of every node of the right branch.
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('root'), n('a'), n('b'), n('a1'), n('a2'), n('a3'), n('b1')],
|
||||
edges: [e('root', 'a'), e('root', 'b'), e('a', 'a1'), e('a', 'a2'), e('a', 'a3'), e('b', 'b1')]
|
||||
})
|
||||
const leftMax = Math.max(...['a', 'a1', 'a2', 'a3'].map((id) => pos.get(id)!.x))
|
||||
const rightMin = Math.min(...['b', 'b1'].map((id) => pos.get(id)!.x))
|
||||
expect(rightMin - leftMax).toBeGreaterThanOrEqual(NODE_WIDTH)
|
||||
})
|
||||
|
||||
it('a join roots its own subtree, centered under its parents', () => {
|
||||
// s1 s2
|
||||
// \ /
|
||||
// j j has 2 parents → excluded from both bands
|
||||
// |
|
||||
// t
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('s1'), n('s2'), n('j'), n('t')],
|
||||
edges: [e('s1', 'j'), e('s2', 'j'), e('j', 't')]
|
||||
})
|
||||
const s1 = pos.get('s1')!
|
||||
const s2 = pos.get('s2')!
|
||||
const j = pos.get('j')!
|
||||
// Sources keep single-node bands (the join didn't widen them).
|
||||
expect(Math.abs(s2.x - s1.x)).toBeLessThanOrEqual(NODE_WIDTH + NODE.gap.horizontal)
|
||||
// Join centered between its parents, one layer below the lowest.
|
||||
expect(j.x).toBeCloseTo((s1.x + s2.x) / 2, 5)
|
||||
expect(j.y).toBeGreaterThan(Math.max(s1.y, s2.y))
|
||||
// Its subtree hangs under it.
|
||||
expect(pos.get('t')!.x).toBe(j.x)
|
||||
expect(pos.get('t')!.y).toBeGreaterThan(j.y)
|
||||
})
|
||||
|
||||
it('pushes a join tree sideways instead of overlapping a band in the same layers', () => {
|
||||
// Both sources also have a private child at the join's layer; the
|
||||
// join's band must not overlap those bands.
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('s1'), n('s2'), n('c1'), n('c2'), n('j')],
|
||||
edges: [e('s1', 'c1'), e('s2', 'c2'), e('s1', 'j'), e('s2', 'j')]
|
||||
})
|
||||
const j = pos.get('j')!
|
||||
for (const other of ['c1', 'c2']) {
|
||||
const o = pos.get(other)!
|
||||
expect(o.y).toBe(j.y)
|
||||
expect(Math.abs(o.x - j.x)).toBeGreaterThanOrEqual(NODE_WIDTH)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to a grid on cyclic input', () => {
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('a'), n('b')],
|
||||
edges: [e('a', 'b'), e('b', 'a')]
|
||||
})
|
||||
expect(pos.size).toBe(2)
|
||||
expect(pos.get('a')).toBeDefined()
|
||||
expect(pos.get('b')).toBeDefined()
|
||||
})
|
||||
|
||||
it('packs disjoint components side by side without overlap', () => {
|
||||
const pos = layoutAssetGraph({
|
||||
nodes: [n('a'), n('b'), n('x'), n('y')],
|
||||
edges: [e('a', 'b'), e('x', 'y')]
|
||||
})
|
||||
const comp1Max = Math.max(pos.get('a')!.x, pos.get('b')!.x)
|
||||
const comp2Min = Math.min(pos.get('x')!.x, pos.get('y')!.x)
|
||||
expect(comp2Min).toBeGreaterThanOrEqual(comp1Max + NODE_WIDTH)
|
||||
})
|
||||
|
||||
it('places the anchor centered above everything', () => {
|
||||
const pos = layoutAssetGraph(
|
||||
{
|
||||
nodes: [n('__add__'), n('a'), n('b')],
|
||||
edges: [e('__add__', 'a'), e('__add__', 'b')]
|
||||
},
|
||||
'__add__'
|
||||
)
|
||||
const anchor = pos.get('__add__')!
|
||||
expect(anchor.y).toBe(0)
|
||||
for (const id of ['a', 'b']) {
|
||||
expect(pos.get(id)!.y).toBeGreaterThan(anchor.y)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { dagStratify, sugiyama, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
|
||||
import type { AssetGraphNodeData } from './types'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
|
||||
@@ -16,6 +15,8 @@ const SIBLING_GAP = NODE.gap.horizontal
|
||||
// into each other's columns — but only ~2×, so they don't drift far apart.
|
||||
const COMPONENT_GAP = SIBLING_GAP * 2
|
||||
|
||||
const LAYER_H = NODE_HEIGHT + LAYER_GAP
|
||||
|
||||
interface GraphInput {
|
||||
nodes: Array<{ id: string; data: AssetGraphNodeData }>
|
||||
edges: Array<{ source: string; target: string }>
|
||||
@@ -26,40 +27,170 @@ interface Positioned {
|
||||
y: number
|
||||
}
|
||||
|
||||
// Sugiyama a single connected component. Returns positions normalized so the
|
||||
// component's left/top edge sits at 0, so the caller can pack components
|
||||
// side-by-side by translating on x. Throws on cyclic input (caller falls back
|
||||
// to a grid for the whole graph).
|
||||
// Horizontal band reserved by a placed subtree, with its vertical (layer)
|
||||
// extent. Two bands may share x only when their layer ranges don't intersect.
|
||||
interface Band {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
// Tidy-tree layout of a single connected component.
|
||||
//
|
||||
// Each node reserves a horizontal band at least as wide as the sum of its
|
||||
// children's bands (`W(n) = max(NODE_WIDTH, Σ W(children) + gaps)`), children
|
||||
// are packed side-by-side inside the parent's band, and the parent is centered
|
||||
// over it. Bands are exclusive, so two nodes with different parents can never
|
||||
// interleave horizontally — the failure mode of the previous Sugiyama layout.
|
||||
//
|
||||
// The band recursion stops at *join points*: a node with two or more parents
|
||||
// belongs to no single parent's band (it would be double-counted and would
|
||||
// force conflicting reservations). Joins instead root their own subtree,
|
||||
// placed after the source trees, centered under the mean x of their parents
|
||||
// and pushed right just enough to not overlap any band whose layer range
|
||||
// intersects theirs.
|
||||
//
|
||||
// y comes from longest-path layering (same top-down orientation as before:
|
||||
// producers above, assets in the middle, consumers below). Returns positions
|
||||
// (band centers) normalized so the component's min x,y = 0. Throws on cyclic
|
||||
// input (caller falls back to a grid for the whole graph).
|
||||
function layoutComponent(
|
||||
nodes: GraphInput['nodes'],
|
||||
edges: GraphInput['edges']
|
||||
): Map<string, Positioned> {
|
||||
const out = new Map<string, Positioned>()
|
||||
const ids = new Set(nodes.map((n) => n.id))
|
||||
|
||||
const parentsByChild = new Map<string, string[]>()
|
||||
for (const n of nodes) parentsByChild.set(n.id, [])
|
||||
const parents = new Map<string, string[]>()
|
||||
const children = new Map<string, string[]>()
|
||||
for (const n of nodes) {
|
||||
parents.set(n.id, [])
|
||||
children.set(n.id, [])
|
||||
}
|
||||
for (const e of edges) {
|
||||
const arr = parentsByChild.get(e.target)
|
||||
if (arr && arr.indexOf(e.source) === -1) arr.push(e.source)
|
||||
if (!ids.has(e.source) || !ids.has(e.target)) continue
|
||||
// Dedup parallel edges (read+write pairs produce two edges between the
|
||||
// same nodes) so they don't skew child ordering or join detection.
|
||||
if (!children.get(e.source)!.includes(e.target)) children.get(e.source)!.push(e.target)
|
||||
if (!parents.get(e.target)!.includes(e.source)) parents.get(e.target)!.push(e.source)
|
||||
}
|
||||
|
||||
const dagNodes = nodes.map((n) => ({ id: n.id, parentIds: parentsByChild.get(n.id) ?? [] }))
|
||||
const dag = dagStratify().id(({ id }: { id: string }) => id)(dagNodes)
|
||||
const layout = sugiyama()
|
||||
// decrossOpt is exponential — only safe on small components.
|
||||
.decross(nodes.length > 30 ? decrossTwoLayer() : decrossOpt())
|
||||
.coord(coordCenter())
|
||||
.nodeSize(
|
||||
() => [NODE_WIDTH + SIBLING_GAP, NODE_HEIGHT + LAYER_GAP] as readonly [number, number]
|
||||
)
|
||||
layout(dag as any)
|
||||
for (const desc of dag.descendants()) {
|
||||
const id = (desc as any).data.id as string
|
||||
out.set(id, {
|
||||
x: (desc as any).x ?? 0,
|
||||
y: ((desc as any).y ?? 0) - (NODE_HEIGHT + LAYER_GAP) / 2
|
||||
})
|
||||
// Kahn topological order — also the cycle guard.
|
||||
const indeg = new Map<string, number>()
|
||||
for (const n of nodes) indeg.set(n.id, parents.get(n.id)!.length)
|
||||
const queue = nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id)
|
||||
const topo: string[] = []
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!
|
||||
topo.push(cur)
|
||||
for (const c of children.get(cur)!) {
|
||||
const d = indeg.get(c)! - 1
|
||||
indeg.set(c, d)
|
||||
if (d === 0) queue.push(c)
|
||||
}
|
||||
}
|
||||
if (topo.length !== nodes.length) throw new Error('cyclic asset graph')
|
||||
|
||||
// Longest-path layering: a node sits one layer below its lowest parent.
|
||||
const layer = new Map<string, number>()
|
||||
for (const id of topo) {
|
||||
const ps = parents.get(id)!
|
||||
layer.set(id, ps.length === 0 ? 0 : Math.max(...ps.map((p) => layer.get(p)!)) + 1)
|
||||
}
|
||||
|
||||
// Spanning forest: single-parent nodes hang under their parent; joins
|
||||
// (≥ 2 parents) root their own tree. Child order follows edge input order
|
||||
// for a stable, deterministic left-to-right.
|
||||
const treeChildren = new Map<string, string[]>()
|
||||
for (const n of nodes) treeChildren.set(n.id, [])
|
||||
for (const n of nodes) {
|
||||
const ps = parents.get(n.id)!
|
||||
if (ps.length === 1) treeChildren.get(ps[0])!.push(n.id)
|
||||
}
|
||||
|
||||
// Band widths, post-order (reverse topo visits children before parents).
|
||||
const W = new Map<string, number>()
|
||||
for (let i = topo.length - 1; i >= 0; i--) {
|
||||
const id = topo[i]
|
||||
const kids = treeChildren.get(id)!
|
||||
const kidsW =
|
||||
kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * Math.max(0, kids.length - 1)
|
||||
W.set(id, Math.max(NODE_WIDTH, kidsW))
|
||||
}
|
||||
|
||||
// Vertical (pixel) extent of a subtree, for band collision checks.
|
||||
function treeSpan(root: string): { top: number; bottom: number } {
|
||||
let lo = layer.get(root)!
|
||||
let hi = lo
|
||||
const stack = [root]
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!
|
||||
const l = layer.get(cur)!
|
||||
if (l < lo) lo = l
|
||||
if (l > hi) hi = l
|
||||
for (const k of treeChildren.get(cur)!) stack.push(k)
|
||||
}
|
||||
return { top: lo * LAYER_H, bottom: hi * LAYER_H + NODE_HEIGHT }
|
||||
}
|
||||
|
||||
// Recursive placement: node centered over its band, children packed
|
||||
// side-by-side and centered within it.
|
||||
function placeTree(id: string, left: number) {
|
||||
const w = W.get(id)!
|
||||
out.set(id, { x: left + w / 2, y: layer.get(id)! * LAYER_H })
|
||||
const kids = treeChildren.get(id)!
|
||||
if (kids.length === 0) return
|
||||
const kidsW =
|
||||
kids.reduce((acc, k) => acc + W.get(k)!, 0) + SIBLING_GAP * (kids.length - 1)
|
||||
let cursor = left + (w - kidsW) / 2
|
||||
for (const k of kids) {
|
||||
placeTree(k, cursor)
|
||||
cursor += W.get(k)! + SIBLING_GAP
|
||||
}
|
||||
}
|
||||
|
||||
const bands: Band[] = []
|
||||
function placeAndRecord(id: string, left: number) {
|
||||
placeTree(id, left)
|
||||
const span = treeSpan(id)
|
||||
bands.push({ left, right: left + W.get(id)!, top: span.top, bottom: span.bottom })
|
||||
}
|
||||
|
||||
// 1. Source trees (true roots), packed left-to-right in input order.
|
||||
let cursor = 0
|
||||
for (const n of nodes) {
|
||||
if (parents.get(n.id)!.length !== 0) continue
|
||||
placeAndRecord(n.id, cursor)
|
||||
cursor += W.get(n.id)! + SIBLING_GAP
|
||||
}
|
||||
|
||||
// 2. Join trees, in topo order so every parent is already placed (it
|
||||
// lives in a source tree or in an earlier join's tree). Centered under
|
||||
// the mean of the parents, then pushed right past any band it would
|
||||
// overlap (same-x is fine when the layer ranges are disjoint).
|
||||
for (const id of topo) {
|
||||
const ps = parents.get(id)!
|
||||
if (ps.length < 2) continue
|
||||
const w = W.get(id)!
|
||||
const span = treeSpan(id)
|
||||
let center = ps.reduce((acc, p) => acc + out.get(p)!.x, 0) / ps.length
|
||||
let moved = true
|
||||
while (moved) {
|
||||
moved = false
|
||||
for (const b of bands) {
|
||||
if (span.top > b.bottom || span.bottom < b.top) continue
|
||||
const left = center - w / 2
|
||||
const right = center + w / 2
|
||||
if (right > b.left - SIBLING_GAP && left < b.right + SIBLING_GAP) {
|
||||
center = b.right + SIBLING_GAP + w / 2
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
placeAndRecord(id, center - w / 2)
|
||||
}
|
||||
|
||||
// Normalize so the component's min x,y = 0.
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
@@ -76,16 +207,13 @@ function layoutComponent(
|
||||
return out
|
||||
}
|
||||
|
||||
// Sugiyama layered layout (top-down, same orientation as the flow editor —
|
||||
// see compoundLayout.ts): producers above → assets in the middle → consumers
|
||||
// Top-down layered layout (same orientation as the flow editor — see
|
||||
// compoundLayout.ts): producers above → assets in the middle → consumers
|
||||
// below.
|
||||
//
|
||||
// Each weakly-connected component (treating edges as undirected) is laid out
|
||||
// independently, then the components are packed left-to-right with a wide
|
||||
// gutter between them. Running Sugiyama over the whole graph at once would
|
||||
// interleave disjoint subgraphs in shared layers, leaving nodes from unrelated
|
||||
// triggers horizontally adjacent; per-component layout makes each subgraph
|
||||
// occupy its own horizontal band whose width is its own max breadth, so they
|
||||
// independently with the tidy-tree algorithm above, then the components are
|
||||
// packed left-to-right with a wide gutter between them, so disjoint subgraphs
|
||||
// read as clearly separated boxes.
|
||||
//
|
||||
// `anchorId` is an optional UI affordance node (the pipeline `+` button) that
|
||||
@@ -93,7 +221,7 @@ function layoutComponent(
|
||||
// disjoint components, so it's excluded from component detection and instead
|
||||
// re-placed centered one layer above the whole packed graph.
|
||||
//
|
||||
// Falls back to a stable grid if d3-dag throws (e.g., cyclic inputs).
|
||||
// Falls back to a stable grid if the component layout throws (cyclic inputs).
|
||||
export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<string, Positioned> {
|
||||
const byId = new Map<string, Positioned>()
|
||||
if (graph.nodes.length === 0) return byId
|
||||
@@ -147,7 +275,7 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri
|
||||
try {
|
||||
// 3. Lay out each component and pack side-by-side. xOffset advances by
|
||||
// the laid-out width of each component (max node center + a node width,
|
||||
// since coordCenter positions are node centers) plus the gutter.
|
||||
// since positions are node centers) plus the gutter.
|
||||
let xOffset = 0
|
||||
for (let c = 0; c < nComp; c++) {
|
||||
const positions = layoutComponent(compNodes[c], compEdges[c])
|
||||
@@ -175,7 +303,7 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri
|
||||
if (p.x > maxX) maxX = p.x
|
||||
if (p.y < minY) minY = p.y
|
||||
}
|
||||
byId.set(anchorId, { x: (minX + maxX) / 2, y: minY - (NODE_HEIGHT + LAYER_GAP) })
|
||||
byId.set(anchorId, { x: (minX + maxX) / 2, y: minY - LAYER_H })
|
||||
let nMinY = Infinity
|
||||
for (const p of byId.values()) if (p.y < nMinY) nMinY = p.y
|
||||
for (const p of byId.values()) p.y -= nMinY
|
||||
@@ -187,7 +315,7 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map<stri
|
||||
graph.nodes.forEach((n, i) => {
|
||||
byId.set(n.id, {
|
||||
x: (i % cols) * (NODE_WIDTH + SIBLING_GAP),
|
||||
y: Math.floor(i / cols) * (NODE_HEIGHT + LAYER_GAP)
|
||||
y: Math.floor(i / cols) * LAYER_H
|
||||
})
|
||||
})
|
||||
return byId
|
||||
|
||||
Reference in New Issue
Block a user