Skip to content

Commit f5cb983

Browse files
feat[frontend](soar): layered layout for flow canvas nodes (#2518)
1 parent 2d49cf6 commit f5cb983

3 files changed

Lines changed: 130 additions & 7 deletions

File tree

frontend/src/features/soar/components/FlowCanvas.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ import { NodeInspector } from './NodeInspector'
2626
import { TriggerInspector } from './TriggerInspector'
2727
import { DAGNode } from './nodes/DAGNode'
2828
import { TriggerNode } from './nodes/TriggerNode'
29+
import { computeLayeredLayout, TRIGGER_LAYOUT_ID } from './layeredLayout'
2930

3031
const NODE_TYPES: NodeTypes = { dag: DAGNode as unknown as NodeTypes[string], trigger: TriggerNode as unknown as NodeTypes[string] }
31-
const TRIGGER_ID = '__trigger__'
32+
const TRIGGER_ID = TRIGGER_LAYOUT_ID
3233

3334
interface Props {
3435
roots: string[]
@@ -64,31 +65,30 @@ function FlowCanvasInner({ roots, nodes, conditions, readOnly, onChange, onCondi
6465
const [inspectorOpen, setInspectorOpen] = useState(true)
6566

6667
const { rfNodes, rfEdges } = useMemo(() => {
67-
const posFor = (id: string, fallback: { x: number; y: number }) =>
68-
layoutRef.current[id] ?? (layoutRef.current[id] = fallback)
68+
// Layered default (trigger → layer 1 → layer 2 …). User drags win via layoutRef.
69+
const layered = computeLayeredLayout(roots, nodes)
70+
const posFor = (id: string) => layoutRef.current[id] ?? layered[id] ?? { x: 0, y: 0 }
6971

7072
const rfN: Node[] = [
7173
{
7274
id: TRIGGER_ID,
7375
type: 'trigger',
74-
position: posFor(TRIGGER_ID, { x: 120, y: 0 }),
76+
position: posFor(TRIGGER_ID),
7577
data: {},
7678
selected: selectedId === TRIGGER_ID,
7779
draggable: !readOnly,
7880
deletable: false,
7981
},
8082
]
81-
let i = 0
8283
for (const [id, n] of Object.entries(nodes)) {
8384
rfN.push({
8485
id,
8586
type: 'dag',
86-
position: posFor(id, { x: (i % 3) * 260, y: 180 + Math.floor(i / 3) * 180 }),
87+
position: posFor(id),
8788
data: { nodeId: id, ...n } as unknown as Record<string, unknown>,
8889
selected: id === selectedId,
8990
draggable: !readOnly,
9091
})
91-
i++
9292
}
9393

9494
const rfE: Edge[] = []
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { computeLayeredLayout, TRIGGER_LAYOUT_ID } from './layeredLayout'
3+
import type { FlowNode } from '../types/soar.types'
4+
5+
const exec = (patch: Partial<FlowNode> = {}): FlowNode => ({ kind: 'executor', executor: 'noop', ...patch })
6+
7+
describe('computeLayeredLayout', () => {
8+
test('trigger is layer 0 and sits above roots', () => {
9+
const pos = computeLayeredLayout(['a'], { a: exec() })
10+
expect(pos[TRIGGER_LAYOUT_ID].y).toBeLessThan(pos.a.y)
11+
})
12+
13+
test('each child sits one layer below its parent', () => {
14+
const pos = computeLayeredLayout(['a'], {
15+
a: exec({ onSuccess: ['b'] }),
16+
b: exec({ onSuccess: ['c'] }),
17+
c: exec(),
18+
})
19+
expect(pos.b.y - pos.a.y).toBe(pos.c.y - pos.b.y)
20+
expect(pos.a.y).toBeLessThan(pos.b.y)
21+
expect(pos.b.y).toBeLessThan(pos.c.y)
22+
})
23+
24+
test('multi-parent join settles at deepest ancestor + 1, not at layer 1', () => {
25+
// trigger -> a -> b -> d, trigger -> c -> d. d must be below b (deepest).
26+
const pos = computeLayeredLayout(['a', 'c'], {
27+
a: exec({ onSuccess: ['b'] }),
28+
b: exec({ onSuccess: ['d'] }),
29+
c: exec({ onSuccess: ['d'] }),
30+
d: exec(),
31+
})
32+
expect(pos.d.y).toBeGreaterThan(pos.b.y)
33+
expect(pos.d.y).toBeGreaterThan(pos.c.y)
34+
})
35+
36+
test('siblings in the same layer share y and separate on x', () => {
37+
const pos = computeLayeredLayout(['a', 'b', 'c'], { a: exec(), b: exec(), c: exec() })
38+
expect(pos.a.y).toBe(pos.b.y)
39+
expect(pos.b.y).toBe(pos.c.y)
40+
expect(pos.a.x).not.toBe(pos.b.x)
41+
expect(pos.b.x).not.toBe(pos.c.x)
42+
})
43+
44+
test('orphan nodes land in a trailing layer, not on top of the trigger', () => {
45+
const pos = computeLayeredLayout(['a'], { a: exec(), orphan: exec() })
46+
expect(pos.orphan.y).toBeGreaterThan(pos.a.y)
47+
})
48+
49+
test('cycles do not hang the layout', () => {
50+
const pos = computeLayeredLayout(['a'], {
51+
a: exec({ onSuccess: ['b'] }),
52+
b: exec({ onSuccess: ['a'] }),
53+
})
54+
expect(pos.a).toBeDefined()
55+
expect(pos.b).toBeDefined()
56+
})
57+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { FlowNode } from '../types/soar.types'
2+
3+
export const TRIGGER_LAYOUT_ID = '__trigger__'
4+
5+
const LAYER_Y_STEP = 200
6+
const NODE_X_STEP = 320
7+
const CANVAS_X_CENTER = 300
8+
const TRIGGER_Y = 0
9+
10+
/** Top-down layered layout: trigger at layer 0, roots at layer 1, and each
11+
* downstream node placed one layer below its deepest parent (longest-path
12+
* toposort). Ids in the same layer are spread horizontally, centered around
13+
* CANVAS_X_CENTER, so trigger → 1st layer → 2nd layer reads top-to-bottom
14+
* with clear separation. Nodes unreachable from the trigger fall into their
15+
* own trailing layer instead of piling on top of the graph. */
16+
export function computeLayeredLayout(
17+
roots: string[],
18+
nodes: Record<string, FlowNode>,
19+
): Record<string, { x: number; y: number }> {
20+
const parents: Record<string, string[]> = { [TRIGGER_LAYOUT_ID]: [] }
21+
for (const id of Object.keys(nodes)) parents[id] = []
22+
for (const r of roots) if (parents[r]) parents[r].push(TRIGGER_LAYOUT_ID)
23+
for (const [id, n] of Object.entries(nodes)) {
24+
for (const c of n.onSuccess ?? []) if (parents[c]) parents[c].push(id)
25+
for (const c of n.onError ?? []) if (parents[c]) parents[c].push(id)
26+
}
27+
28+
const layer: Record<string, number> = { [TRIGGER_LAYOUT_ID]: 0 }
29+
// Iterate to a fixed point: each pass promotes a node to max(parent)+1 if
30+
// any parent settled deeper. Caps at nodeCount+1 passes so cycles can't spin.
31+
const cap = Object.keys(nodes).length + 2
32+
for (let pass = 0; pass < cap; pass++) {
33+
let changed = false
34+
for (const id of Object.keys(nodes)) {
35+
const ps = parents[id]
36+
let best = -Infinity
37+
for (const p of ps) if (layer[p] !== undefined && layer[p] > best) best = layer[p]
38+
if (best === -Infinity) continue
39+
const next = best + 1
40+
if ((layer[id] ?? -1) < next) {
41+
layer[id] = next
42+
changed = true
43+
}
44+
}
45+
if (!changed) break
46+
}
47+
48+
const reachedMax = Math.max(0, ...Object.values(layer))
49+
const orphanLayer = reachedMax + 1
50+
for (const id of Object.keys(nodes)) if (layer[id] === undefined) layer[id] = orphanLayer
51+
52+
const byLayer: Record<number, string[]> = {}
53+
for (const [id, l] of Object.entries(layer)) (byLayer[l] ??= []).push(id)
54+
55+
const positions: Record<string, { x: number; y: number }> = {}
56+
for (const [lStr, ids] of Object.entries(byLayer)) {
57+
const l = Number(lStr)
58+
ids.sort()
59+
const spanW = (ids.length - 1) * NODE_X_STEP
60+
const startX = CANVAS_X_CENTER - spanW / 2
61+
ids.forEach((id, i) => {
62+
positions[id] = { x: startX + i * NODE_X_STEP, y: TRIGGER_Y + l * LAYER_Y_STEP }
63+
})
64+
}
65+
return positions
66+
}

0 commit comments

Comments
 (0)