From e43080ecbb5c6915da35889b709a617cbfe1ae73 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 12 Jun 2026 18:22:46 +0200 Subject: [PATCH] feat: band-reserving tidy-tree asset graph layout with join breakpoints Co-Authored-By: Claude Fable 5 --- .../AssetGraph/assetGraphLayout.test.ts | 120 +++++++++++ .../assets/AssetGraph/assetGraphLayout.ts | 200 ++++++++++++++---- 2 files changed, 284 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts new file mode 100644 index 0000000000..a5a92f61c2 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.test.ts @@ -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) + } + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts index 2ba9be1eb1..cf55db6e0a 100644 --- a/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts +++ b/frontend/src/lib/components/assets/AssetGraph/assetGraphLayout.ts @@ -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 { const out = new Map() + const ids = new Set(nodes.map((n) => n.id)) - const parentsByChild = new Map() - for (const n of nodes) parentsByChild.set(n.id, []) + const parents = new Map() + const children = new Map() + 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() + 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() + 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() + 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() + 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 { const byId = new Map() if (graph.nodes.length === 0) return byId @@ -147,7 +275,7 @@ export function layoutAssetGraph(graph: GraphInput, anchorId?: string): Map 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 { 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