Skip to content

Commit 78c774f

Browse files
authored
Merge pull request #54 from radical-data/autolayout
feat: use vertical auto-layout as default
2 parents f0b5799 + 41a0e7a commit 78c774f

4 files changed

Lines changed: 196 additions & 47 deletions

File tree

src/lib/components/FlowEditor.svelte

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<script lang="ts">
2-
import type { EdgeTypes, OnBeforeConnect, OnBeforeReconnect } from '@xyflow/svelte';
3-
import { Background, Controls, MiniMap, Panel, SvelteFlow } from '@xyflow/svelte';
4-
import { setContext } from 'svelte';
2+
import type { EdgeTypes, Node, OnBeforeConnect, OnBeforeReconnect } from '@xyflow/svelte';
3+
import { Background, Controls, MiniMap, SvelteFlow } from '@xyflow/svelte';
4+
import { onDestroy, setContext } from 'svelte';
55
66
import type { Issue } from '../engine/HydraEngine.js';
77
import { getAllDefinitions } from '../nodes/registry.js';
88
import type { InputValue, IREdge, IRNode, NodeDefinition } from '../types.js';
99
import { getLayoutedElements } from '../utils/layout.js';
10+
import { createLayoutAnimator } from '../utils/layoutAnimator.js';
1011
import CustomEdge from './CustomEdge.svelte';
1112
import CustomNode from './CustomNode.svelte';
1213
let {
@@ -23,6 +24,9 @@
2324
validationIssues?: Issue[];
2425
}>();
2526
27+
let displayNodes = $state.raw<IRNode[]>([]);
28+
let isInitialized = $state(false);
29+
2630
type NodeValidationStatus = {
2731
hasError: boolean;
2832
hasWarning: boolean;
@@ -127,11 +131,83 @@
127131
});
128132
}
129133
130-
function onLayout(direction: 'TB' | 'LR') {
131-
const layouted = getLayoutedElements(nodes, edges, direction);
134+
const POSITION_TOLERANCE = 0.5;
135+
const LAYOUT_DIRECTION = 'TB';
136+
137+
const animator = createLayoutAnimator(250);
138+
139+
function shouldApplyAutoLayout(currentNodes: IRNode[], layoutedNodes: IRNode[]): boolean {
140+
if (currentNodes.length !== layoutedNodes.length) {
141+
return true;
142+
}
143+
144+
const layoutMap = new Map(layoutedNodes.map((node) => [node.id, node]));
145+
146+
for (const node of currentNodes) {
147+
const layoutNode = layoutMap.get(node.id);
148+
if (!layoutNode) {
149+
return true;
150+
}
151+
152+
const deltaX = Math.abs((node.position?.x ?? 0) - layoutNode.position.x);
153+
const deltaY = Math.abs((node.position?.y ?? 0) - layoutNode.position.y);
154+
155+
if (deltaX > POSITION_TOLERANCE || deltaY > POSITION_TOLERANCE) {
156+
return true;
157+
}
158+
159+
if (
160+
node.sourcePosition !== layoutNode.sourcePosition ||
161+
node.targetPosition !== layoutNode.targetPosition
162+
) {
163+
return true;
164+
}
165+
}
166+
167+
return false;
168+
}
169+
170+
$effect(() => {
171+
if (nodes.length === 0) {
172+
return;
173+
}
174+
175+
const layouted = getLayoutedElements(nodes, edges, LAYOUT_DIRECTION);
176+
177+
if (!shouldApplyAutoLayout(nodes, layouted.nodes)) {
178+
return;
179+
}
180+
132181
nodes = [...layouted.nodes];
133182
edges = [...layouted.edges];
134-
}
183+
});
184+
185+
$effect(() => {
186+
if (nodes.length === 0) {
187+
isInitialized = true;
188+
animator.handleLayoutChange([], (next) => {
189+
displayNodes = next;
190+
});
191+
return;
192+
}
193+
194+
if (!isInitialized) {
195+
displayNodes = [...nodes];
196+
isInitialized = true;
197+
animator.handleLayoutChange(nodes, (next) => {
198+
displayNodes = next;
199+
});
200+
return;
201+
}
202+
203+
animator.handleLayoutChange(nodes, (next) => {
204+
displayNodes = next;
205+
});
206+
});
207+
208+
onDestroy(() => {
209+
animator.stop();
210+
});
135211
136212
function disconnectExistingTarget(
137213
targetNodeId: string | null | undefined,
@@ -181,24 +257,19 @@
181257
<div class="flow-canvas">
182258
{#if Object.keys(nodeTypes).length > 0}
183259
<SvelteFlow
184-
bind:nodes
260+
bind:nodes={displayNodes as Node[]}
185261
bind:edges
186262
{nodeTypes}
187263
{edgeTypes}
188264
fitView
265+
nodesDraggable={false}
189266
onbeforeconnect={handleBeforeConnect}
190267
onbeforereconnect={handleBeforeReconnect}
191268
class="flow-container"
192269
>
193270
<Background />
194-
<Controls />
271+
<Controls showLock={false} />
195272
<MiniMap />
196-
<Panel position="top-right">
197-
<div class="layout-buttons">
198-
<button onclick={() => onLayout('TB')} class="layout-btn"> Vertical </button>
199-
<button onclick={() => onLayout('LR')} class="layout-btn"> Horizontal </button>
200-
</div>
201-
</Panel>
202273
</SvelteFlow>
203274
{:else}
204275
<div class="loading-canvas">
@@ -270,33 +341,6 @@
270341
padding: 6px 12px;
271342
}
272343
273-
.layout-buttons {
274-
display: flex;
275-
gap: 8px;
276-
background: rgba(255, 255, 255, 0.95);
277-
padding: 12px;
278-
border-radius: 8px;
279-
backdrop-filter: blur(4px);
280-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
281-
}
282-
283-
.layout-btn {
284-
padding: 8px 12px;
285-
background: #4caf50;
286-
color: white;
287-
border: none;
288-
border-radius: 6px;
289-
cursor: pointer;
290-
font-size: 12px;
291-
font-weight: 600;
292-
transition: all 0.2s;
293-
}
294-
295-
.layout-btn:hover {
296-
background: #45a049;
297-
transform: translateY(-1px);
298-
}
299-
300344
.flow-canvas {
301345
flex: 1;
302346
position: relative;

src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface IRNode {
3737
type: string;
3838
data: Record<string, InputValue>;
3939
position: { x: number; y: number };
40+
sourcePosition?: 'left' | 'right' | 'top' | 'bottom';
41+
targetPosition?: 'left' | 'right' | 'top' | 'bottom';
4042
}
4143

4244
export interface IREdge {

src/lib/utils/layout.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ const nodeHeight = 120;
88
export function getLayoutedElements(
99
nodes: IRNode[],
1010
edges: IREdge[],
11-
direction: 'TB' | 'LR' = 'TB'
12-
) {
11+
direction: 'TB' = 'TB'
12+
): { nodes: IRNode[]; edges: IREdge[] } {
1313
const dagreGraph = new Dagre.graphlib.Graph();
1414
dagreGraph.setDefaultEdgeLabel(() => ({}));
1515

@@ -33,18 +33,17 @@ export function getLayoutedElements(
3333

3434
Dagre.layout(dagreGraph);
3535

36-
const layoutedNodes = nodes.map((node) => {
36+
const layoutedNodes: IRNode[] = nodes.map((node) => {
3737
const nodeWithPosition = dagreGraph.node(node.id);
38-
const isHorizontal = direction === 'LR';
3938

4039
return {
4140
...node,
4241
position: {
4342
x: nodeWithPosition.x - nodeWidth / 2,
4443
y: nodeWithPosition.y - nodeHeight / 2
4544
},
46-
sourcePosition: isHorizontal ? 'right' : 'bottom',
47-
targetPosition: isHorizontal ? 'left' : 'top'
45+
sourcePosition: 'bottom' as const,
46+
targetPosition: 'top' as const
4847
};
4948
});
5049

src/lib/utils/layoutAnimator.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { IRNode } from '../types.js';
2+
3+
export type LayoutAnimator = {
4+
/** Call when canonical nodes change; onFrame is called with display nodes (interpolated or final). */
5+
handleLayoutChange(nodes: IRNode[], onFrame: (displayNodes: IRNode[]) => void): void;
6+
/** Stop any running animation (e.g. on destroy). */
7+
stop(): void;
8+
};
9+
10+
export function createLayoutAnimator(duration = 250): LayoutAnimator {
11+
let isAnimating = false;
12+
let animationFrameId: number | null = null;
13+
let previousPositions: Map<string, { x: number; y: number }> | null = null;
14+
15+
function stop() {
16+
if (animationFrameId !== null) {
17+
cancelAnimationFrame(animationFrameId);
18+
animationFrameId = null;
19+
}
20+
isAnimating = false;
21+
}
22+
23+
function startAnimation(
24+
fromById: Map<string, { x: number; y: number }>,
25+
toById: Map<string, { x: number; y: number }>,
26+
nodes: IRNode[],
27+
onFrame: (displayNodes: IRNode[]) => void
28+
) {
29+
stop();
30+
31+
isAnimating = true;
32+
const start = performance.now();
33+
34+
const step = (now: number) => {
35+
const elapsed = now - start;
36+
const tRaw = elapsed / duration;
37+
const t = tRaw >= 1 ? 1 : tRaw;
38+
39+
const eased = t * (2 - t);
40+
41+
const displayNodes = nodes.map((node) => {
42+
const from = fromById.get(node.id) ?? node.position;
43+
const to = toById.get(node.id) ?? node.position;
44+
45+
const x = from.x + (to.x - from.x) * eased;
46+
const y = from.y + (to.y - from.y) * eased;
47+
48+
return {
49+
...node,
50+
position: { x, y },
51+
sourcePosition: node.sourcePosition,
52+
targetPosition: node.targetPosition
53+
};
54+
});
55+
56+
onFrame(displayNodes);
57+
58+
if (t < 1 && isAnimating) {
59+
animationFrameId = requestAnimationFrame(step);
60+
} else {
61+
stop();
62+
onFrame(nodes);
63+
}
64+
};
65+
66+
animationFrameId = requestAnimationFrame(step);
67+
}
68+
69+
function handleLayoutChange(nodes: IRNode[], onFrame: (displayNodes: IRNode[]) => void) {
70+
if (nodes.length === 0) {
71+
previousPositions = null;
72+
onFrame([]);
73+
return;
74+
}
75+
76+
const fromById =
77+
previousPositions ?? new Map(nodes.map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
78+
79+
const toById = new Map(nodes.map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
80+
81+
let needsAnimation = false;
82+
for (const node of nodes) {
83+
const from = fromById.get(node.id);
84+
const to = toById.get(node.id);
85+
if (!to) continue;
86+
87+
if (!from || from.x !== to.x || from.y !== to.y) {
88+
needsAnimation = true;
89+
break;
90+
}
91+
}
92+
93+
previousPositions = toById;
94+
95+
if (!needsAnimation) {
96+
onFrame(nodes);
97+
return;
98+
}
99+
100+
startAnimation(fromById, toById, nodes, onFrame);
101+
}
102+
103+
return { handleLayoutChange, stop };
104+
}

0 commit comments

Comments
 (0)