diff --git a/frontend/src/features/soar/components/FlowCanvas.tsx b/frontend/src/features/soar/components/FlowCanvas.tsx index ab1334f92..4b063300a 100644 --- a/frontend/src/features/soar/components/FlowCanvas.tsx +++ b/frontend/src/features/soar/components/FlowCanvas.tsx @@ -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[] @@ -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, selected: id === selectedId, draggable: !readOnly, }) - i++ } const rfE: Edge[] = [] diff --git a/frontend/src/features/soar/components/layeredLayout.test.ts b/frontend/src/features/soar/components/layeredLayout.test.ts new file mode 100644 index 000000000..26187a2ea --- /dev/null +++ b/frontend/src/features/soar/components/layeredLayout.test.ts @@ -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 => ({ 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() + }) +}) diff --git a/frontend/src/features/soar/components/layeredLayout.ts b/frontend/src/features/soar/components/layeredLayout.ts new file mode 100644 index 000000000..2f0f33339 --- /dev/null +++ b/frontend/src/features/soar/components/layeredLayout.ts @@ -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, +): Record { + const parents: Record = { [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 = { [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 = {} + for (const [id, l] of Object.entries(layer)) (byLayer[l] ??= []).push(id) + + const positions: Record = {} + 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 +}