Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions frontend/src/features/soar/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import { NodeInspector } from './NodeInspector'
import { TriggerInspector } from './TriggerInspector'
import { DAGNode } from './nodes/DAGNode'
import { TriggerNode } from './nodes/TriggerNode'
import { computeLayeredLayout, TRIGGER_LAYOUT_ID } from './layeredLayout'

const NODE_TYPES: NodeTypes = { dag: DAGNode as unknown as NodeTypes[string], trigger: TriggerNode as unknown as NodeTypes[string] }
const TRIGGER_ID = '__trigger__'
const TRIGGER_ID = TRIGGER_LAYOUT_ID

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

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

const rfN: Node[] = [
{
id: TRIGGER_ID,
type: 'trigger',
position: posFor(TRIGGER_ID, { x: 120, y: 0 }),
position: posFor(TRIGGER_ID),
data: {},
selected: selectedId === TRIGGER_ID,
draggable: !readOnly,
deletable: false,
},
]
let i = 0
for (const [id, n] of Object.entries(nodes)) {
rfN.push({
id,
type: 'dag',
position: posFor(id, { x: (i % 3) * 260, y: 180 + Math.floor(i / 3) * 180 }),
position: posFor(id),
data: { nodeId: id, ...n } as unknown as Record<string, unknown>,
selected: id === selectedId,
draggable: !readOnly,
})
i++
}

const rfE: Edge[] = []
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/features/soar/components/layeredLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from 'vitest'
import { computeLayeredLayout, TRIGGER_LAYOUT_ID } from './layeredLayout'
import type { FlowNode } from '../types/soar.types'

const exec = (patch: Partial<FlowNode> = {}): FlowNode => ({ kind: 'executor', executor: 'noop', ...patch })

describe('computeLayeredLayout', () => {
test('trigger is layer 0 and sits above roots', () => {
const pos = computeLayeredLayout(['a'], { a: exec() })
expect(pos[TRIGGER_LAYOUT_ID].y).toBeLessThan(pos.a.y)
})

test('each child sits one layer below its parent', () => {
const pos = computeLayeredLayout(['a'], {
a: exec({ onSuccess: ['b'] }),
b: exec({ onSuccess: ['c'] }),
c: exec(),
})
expect(pos.b.y - pos.a.y).toBe(pos.c.y - pos.b.y)
expect(pos.a.y).toBeLessThan(pos.b.y)
expect(pos.b.y).toBeLessThan(pos.c.y)
})

test('multi-parent join settles at deepest ancestor + 1, not at layer 1', () => {
// trigger -> a -> b -> d, trigger -> c -> d. d must be below b (deepest).
const pos = computeLayeredLayout(['a', 'c'], {
a: exec({ onSuccess: ['b'] }),
b: exec({ onSuccess: ['d'] }),
c: exec({ onSuccess: ['d'] }),
d: exec(),
})
expect(pos.d.y).toBeGreaterThan(pos.b.y)
expect(pos.d.y).toBeGreaterThan(pos.c.y)
})

test('siblings in the same layer share y and separate on x', () => {
const pos = computeLayeredLayout(['a', 'b', 'c'], { a: exec(), b: exec(), c: exec() })
expect(pos.a.y).toBe(pos.b.y)
expect(pos.b.y).toBe(pos.c.y)
expect(pos.a.x).not.toBe(pos.b.x)
expect(pos.b.x).not.toBe(pos.c.x)
})

test('orphan nodes land in a trailing layer, not on top of the trigger', () => {
const pos = computeLayeredLayout(['a'], { a: exec(), orphan: exec() })
expect(pos.orphan.y).toBeGreaterThan(pos.a.y)
})

test('cycles do not hang the layout', () => {
const pos = computeLayeredLayout(['a'], {
a: exec({ onSuccess: ['b'] }),
b: exec({ onSuccess: ['a'] }),
})
expect(pos.a).toBeDefined()
expect(pos.b).toBeDefined()
})
})
66 changes: 66 additions & 0 deletions frontend/src/features/soar/components/layeredLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { FlowNode } from '../types/soar.types'

export const TRIGGER_LAYOUT_ID = '__trigger__'

const LAYER_Y_STEP = 200
const NODE_X_STEP = 320
const CANVAS_X_CENTER = 300
const TRIGGER_Y = 0

/** Top-down layered layout: trigger at layer 0, roots at layer 1, and each
* downstream node placed one layer below its deepest parent (longest-path
* toposort). Ids in the same layer are spread horizontally, centered around
* CANVAS_X_CENTER, so trigger → 1st layer → 2nd layer reads top-to-bottom
* with clear separation. Nodes unreachable from the trigger fall into their
* own trailing layer instead of piling on top of the graph. */
export function computeLayeredLayout(
roots: string[],
nodes: Record<string, FlowNode>,
): Record<string, { x: number; y: number }> {
const parents: Record<string, string[]> = { [TRIGGER_LAYOUT_ID]: [] }
for (const id of Object.keys(nodes)) parents[id] = []
for (const r of roots) if (parents[r]) parents[r].push(TRIGGER_LAYOUT_ID)
for (const [id, n] of Object.entries(nodes)) {
for (const c of n.onSuccess ?? []) if (parents[c]) parents[c].push(id)
for (const c of n.onError ?? []) if (parents[c]) parents[c].push(id)
}

const layer: Record<string, number> = { [TRIGGER_LAYOUT_ID]: 0 }
// Iterate to a fixed point: each pass promotes a node to max(parent)+1 if
// any parent settled deeper. Caps at nodeCount+1 passes so cycles can't spin.
const cap = Object.keys(nodes).length + 2
for (let pass = 0; pass < cap; pass++) {
let changed = false
for (const id of Object.keys(nodes)) {
const ps = parents[id]
let best = -Infinity
for (const p of ps) if (layer[p] !== undefined && layer[p] > best) best = layer[p]
if (best === -Infinity) continue
const next = best + 1
if ((layer[id] ?? -1) < next) {
layer[id] = next
changed = true
}
}
if (!changed) break
}

const reachedMax = Math.max(0, ...Object.values(layer))
const orphanLayer = reachedMax + 1
for (const id of Object.keys(nodes)) if (layer[id] === undefined) layer[id] = orphanLayer

const byLayer: Record<number, string[]> = {}
for (const [id, l] of Object.entries(layer)) (byLayer[l] ??= []).push(id)

const positions: Record<string, { x: number; y: number }> = {}
for (const [lStr, ids] of Object.entries(byLayer)) {
const l = Number(lStr)
ids.sort()
const spanW = (ids.length - 1) * NODE_X_STEP
const startX = CANVAS_X_CENTER - spanW / 2
ids.forEach((id, i) => {
positions[id] = { x: startX + i * NODE_X_STEP, y: TRIGGER_Y + l * LAYER_Y_STEP }
})
}
return positions
}
Loading