@pyreon/flow 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/flow.ts ADDED
@@ -0,0 +1,1141 @@
1
+ import { batch, computed, signal } from '@pyreon/reactivity'
2
+ import { computeLayout } from './layout'
3
+ import type {
4
+ Connection,
5
+ FlowConfig,
6
+ FlowEdge,
7
+ FlowInstance,
8
+ FlowNode,
9
+ LayoutAlgorithm,
10
+ LayoutOptions,
11
+ NodeChange,
12
+ XYPosition,
13
+ } from './types'
14
+
15
+ /**
16
+ * Generate a unique edge id from source/target.
17
+ */
18
+ function edgeId(edge: FlowEdge): string {
19
+ if (edge.id) return edge.id
20
+ const sh = edge.sourceHandle ? `-${edge.sourceHandle}` : ''
21
+ const th = edge.targetHandle ? `-${edge.targetHandle}` : ''
22
+ return `e-${edge.source}${sh}-${edge.target}${th}`
23
+ }
24
+
25
+ /**
26
+ * Create a reactive flow instance — the core state manager for flow diagrams.
27
+ *
28
+ * All state is signal-based. Nodes, edges, viewport, and selection are
29
+ * reactive and update the UI automatically when modified.
30
+ *
31
+ * @param config - Initial configuration with nodes, edges, and options
32
+ * @returns A FlowInstance with signals and methods for managing the diagram
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * const flow = createFlow({
37
+ * nodes: [
38
+ * { id: '1', position: { x: 0, y: 0 }, data: { label: 'Start' } },
39
+ * { id: '2', position: { x: 200, y: 100 }, data: { label: 'End' } },
40
+ * ],
41
+ * edges: [{ source: '1', target: '2' }],
42
+ * })
43
+ *
44
+ * flow.nodes() // reactive node list
45
+ * flow.viewport() // { x: 0, y: 0, zoom: 1 }
46
+ * flow.addNode({ id: '3', position: { x: 400, y: 0 }, data: { label: 'New' } })
47
+ * flow.layout('layered', { direction: 'RIGHT' })
48
+ * ```
49
+ */
50
+ export function createFlow(config: FlowConfig = {}): FlowInstance {
51
+ const {
52
+ nodes: initialNodes = [],
53
+ edges: initialEdges = [],
54
+ defaultEdgeType = 'bezier',
55
+ minZoom = 0.1,
56
+ maxZoom = 4,
57
+ snapToGrid = false,
58
+ snapGrid = 15,
59
+ connectionRules,
60
+ } = config
61
+
62
+ // Ensure all edges have ids
63
+ const edgesWithIds = initialEdges.map((e) => ({
64
+ ...e,
65
+ id: edgeId(e),
66
+ type: e.type ?? defaultEdgeType,
67
+ }))
68
+
69
+ // ── Core signals ─────────────────────────────────────────────────────────
70
+
71
+ const nodes = signal<FlowNode[]>([...initialNodes])
72
+ const edges = signal<FlowEdge[]>(edgesWithIds)
73
+ const viewport = signal({ x: 0, y: 0, zoom: 1 })
74
+ const containerSize = signal({ width: 800, height: 600 })
75
+
76
+ // Track selected state separately for O(1) lookups
77
+ const selectedNodeIds = signal(new Set<string>())
78
+ const selectedEdgeIds = signal(new Set<string>())
79
+
80
+ // ── Computed ─────────────────────────────────────────────────────────────
81
+
82
+ const zoom = computed(() => viewport().zoom)
83
+
84
+ const selectedNodes = computed(() => [...selectedNodeIds()])
85
+ const selectedEdges = computed(() => [...selectedEdgeIds()])
86
+
87
+ // ── Listeners ────────────────────────────────────────────────────────────
88
+
89
+ const connectListeners = new Set<(connection: Connection) => void>()
90
+ const nodesChangeListeners = new Set<(changes: NodeChange[]) => void>()
91
+ const nodeClickListeners = new Set<(node: FlowNode) => void>()
92
+ const edgeClickListeners = new Set<(edge: FlowEdge) => void>()
93
+ const nodeDragStartListeners = new Set<(node: FlowNode) => void>()
94
+ const nodeDragEndListeners = new Set<(node: FlowNode) => void>()
95
+ const nodeDoubleClickListeners = new Set<(node: FlowNode) => void>()
96
+
97
+ function emitNodeChanges(changes: NodeChange[]) {
98
+ for (const cb of nodesChangeListeners) cb(changes)
99
+ }
100
+
101
+ // ── Node operations ──────────────────────────────────────────────────────
102
+
103
+ function getNode(id: string): FlowNode | undefined {
104
+ return nodes.peek().find((n) => n.id === id)
105
+ }
106
+
107
+ function addNode(node: FlowNode): void {
108
+ nodes.update((nds) => [...nds, node])
109
+ }
110
+
111
+ function removeNode(id: string): void {
112
+ batch(() => {
113
+ nodes.update((nds) => nds.filter((n) => n.id !== id))
114
+ // Remove connected edges
115
+ edges.update((eds) =>
116
+ eds.filter((e) => e.source !== id && e.target !== id),
117
+ )
118
+ selectedNodeIds.update((set) => {
119
+ const next = new Set(set)
120
+ next.delete(id)
121
+ return next
122
+ })
123
+ })
124
+ emitNodeChanges([{ type: 'remove', id }])
125
+ }
126
+
127
+ function updateNode(id: string, update: Partial<FlowNode>): void {
128
+ nodes.update((nds) =>
129
+ nds.map((n) => (n.id === id ? { ...n, ...update } : n)),
130
+ )
131
+ }
132
+
133
+ function updateNodePosition(id: string, position: XYPosition): void {
134
+ let pos = snapToGrid
135
+ ? {
136
+ x: Math.round(position.x / snapGrid) * snapGrid,
137
+ y: Math.round(position.y / snapGrid) * snapGrid,
138
+ }
139
+ : position
140
+
141
+ // Apply extent clamping
142
+ const node = getNode(id)
143
+ pos = clampToExtent(pos, node?.width, node?.height)
144
+
145
+ nodes.update((nds) =>
146
+ nds.map((n) => (n.id === id ? { ...n, position: pos } : n)),
147
+ )
148
+ emitNodeChanges([{ type: 'position', id, position: pos }])
149
+ }
150
+
151
+ // ── Edge operations ──────────────────────────────────────────────────────
152
+
153
+ function getEdge(id: string): FlowEdge | undefined {
154
+ return edges.peek().find((e) => e.id === id)
155
+ }
156
+
157
+ function addEdge(edge: FlowEdge): void {
158
+ const newEdge = {
159
+ ...edge,
160
+ id: edgeId(edge),
161
+ type: edge.type ?? defaultEdgeType,
162
+ }
163
+
164
+ // Don't add duplicate edges
165
+ const existing = edges.peek()
166
+ if (existing.some((e) => e.id === newEdge.id)) return
167
+
168
+ edges.update((eds) => [...eds, newEdge])
169
+
170
+ // Notify connect listeners
171
+ const connection: Connection = {
172
+ source: edge.source,
173
+ target: edge.target,
174
+ sourceHandle: edge.sourceHandle,
175
+ targetHandle: edge.targetHandle,
176
+ }
177
+ for (const cb of connectListeners) cb(connection)
178
+ }
179
+
180
+ function removeEdge(id: string): void {
181
+ edges.update((eds) => eds.filter((e) => e.id !== id))
182
+ selectedEdgeIds.update((set) => {
183
+ const next = new Set(set)
184
+ next.delete(id)
185
+ return next
186
+ })
187
+ }
188
+
189
+ function isValidConnection(connection: Connection): boolean {
190
+ if (!connectionRules) return true
191
+
192
+ // Find source node type
193
+ const sourceNode = getNode(connection.source)
194
+ if (!sourceNode) return false
195
+
196
+ const sourceType = sourceNode.type ?? 'default'
197
+ const rule = connectionRules[sourceType]
198
+ if (!rule) return true // no rule = allow
199
+
200
+ // Find target node type
201
+ const targetNode = getNode(connection.target)
202
+ if (!targetNode) return false
203
+
204
+ const targetType = targetNode.type ?? 'default'
205
+ return rule.outputs.includes(targetType)
206
+ }
207
+
208
+ // ── Selection ────────────────────────────────────────────────────────────
209
+
210
+ function selectNode(id: string, additive = false): void {
211
+ selectedNodeIds.update((set) => {
212
+ const next = additive ? new Set(set) : new Set<string>()
213
+ next.add(id)
214
+ return next
215
+ })
216
+ if (!additive) {
217
+ selectedEdgeIds.set(new Set())
218
+ }
219
+ }
220
+
221
+ function deselectNode(id: string): void {
222
+ selectedNodeIds.update((set) => {
223
+ const next = new Set(set)
224
+ next.delete(id)
225
+ return next
226
+ })
227
+ }
228
+
229
+ function selectEdge(id: string, additive = false): void {
230
+ selectedEdgeIds.update((set) => {
231
+ const next = additive ? new Set(set) : new Set<string>()
232
+ next.add(id)
233
+ return next
234
+ })
235
+ if (!additive) {
236
+ selectedNodeIds.set(new Set())
237
+ }
238
+ }
239
+
240
+ function clearSelection(): void {
241
+ batch(() => {
242
+ selectedNodeIds.set(new Set())
243
+ selectedEdgeIds.set(new Set())
244
+ })
245
+ }
246
+
247
+ function selectAll(): void {
248
+ selectedNodeIds.set(new Set(nodes.peek().map((n) => n.id)))
249
+ }
250
+
251
+ function deleteSelected(): void {
252
+ batch(() => {
253
+ const nodeIdsToRemove = selectedNodeIds.peek()
254
+ const edgeIdsToRemove = selectedEdgeIds.peek()
255
+
256
+ if (nodeIdsToRemove.size > 0) {
257
+ nodes.update((nds) => nds.filter((n) => !nodeIdsToRemove.has(n.id)))
258
+ // Also remove edges connected to deleted nodes
259
+ edges.update((eds) =>
260
+ eds.filter(
261
+ (e) =>
262
+ !nodeIdsToRemove.has(e.source) &&
263
+ !nodeIdsToRemove.has(e.target) &&
264
+ !edgeIdsToRemove.has(e.id!),
265
+ ),
266
+ )
267
+ } else if (edgeIdsToRemove.size > 0) {
268
+ edges.update((eds) => eds.filter((e) => !edgeIdsToRemove.has(e.id!)))
269
+ }
270
+
271
+ selectedNodeIds.set(new Set())
272
+ selectedEdgeIds.set(new Set())
273
+ })
274
+ }
275
+
276
+ // ── Viewport ─────────────────────────────────────────────────────────────
277
+
278
+ function fitView(
279
+ nodeIds?: string[],
280
+ padding = config.fitViewPadding ?? 0.1,
281
+ ): void {
282
+ const targetNodes = nodeIds
283
+ ? nodes.peek().filter((n) => nodeIds.includes(n.id))
284
+ : nodes.peek()
285
+
286
+ if (targetNodes.length === 0) return
287
+
288
+ let minX = Number.POSITIVE_INFINITY
289
+ let minY = Number.POSITIVE_INFINITY
290
+ let maxX = Number.NEGATIVE_INFINITY
291
+ let maxY = Number.NEGATIVE_INFINITY
292
+
293
+ for (const node of targetNodes) {
294
+ const w = node.width ?? 150
295
+ const h = node.height ?? 40
296
+ minX = Math.min(minX, node.position.x)
297
+ minY = Math.min(minY, node.position.y)
298
+ maxX = Math.max(maxX, node.position.x + w)
299
+ maxY = Math.max(maxY, node.position.y + h)
300
+ }
301
+
302
+ const graphWidth = maxX - minX
303
+ const graphHeight = maxY - minY
304
+
305
+ const { width: containerWidth, height: containerHeight } =
306
+ containerSize.peek()
307
+
308
+ const zoomX = containerWidth / (graphWidth * (1 + padding * 2))
309
+ const zoomY = containerHeight / (graphHeight * (1 + padding * 2))
310
+ const newZoom = Math.min(Math.max(Math.min(zoomX, zoomY), minZoom), maxZoom)
311
+
312
+ const centerX = (minX + maxX) / 2
313
+ const centerY = (minY + maxY) / 2
314
+
315
+ viewport.set({
316
+ x: containerWidth / 2 - centerX * newZoom,
317
+ y: containerHeight / 2 - centerY * newZoom,
318
+ zoom: newZoom,
319
+ })
320
+ }
321
+
322
+ function zoomTo(z: number): void {
323
+ viewport.update((v) => ({
324
+ ...v,
325
+ zoom: Math.min(Math.max(z, minZoom), maxZoom),
326
+ }))
327
+ }
328
+
329
+ function zoomIn(): void {
330
+ viewport.update((v) => ({
331
+ ...v,
332
+ zoom: Math.min(v.zoom * 1.2, maxZoom),
333
+ }))
334
+ }
335
+
336
+ function zoomOut(): void {
337
+ viewport.update((v) => ({
338
+ ...v,
339
+ zoom: Math.max(v.zoom / 1.2, minZoom),
340
+ }))
341
+ }
342
+
343
+ function panTo(position: XYPosition): void {
344
+ viewport.update((v) => ({
345
+ ...v,
346
+ x: -position.x * v.zoom,
347
+ y: -position.y * v.zoom,
348
+ }))
349
+ }
350
+
351
+ function isNodeVisible(id: string): boolean {
352
+ const node = getNode(id)
353
+ if (!node) return false
354
+ // Simplified check — actual implementation would use container dimensions
355
+ const v = viewport.peek()
356
+ const w = node.width ?? 150
357
+ const h = node.height ?? 40
358
+ const screenX = node.position.x * v.zoom + v.x
359
+ const screenY = node.position.y * v.zoom + v.y
360
+ const screenW = w * v.zoom
361
+ const screenH = h * v.zoom
362
+ const { width: cw, height: ch } = containerSize.peek()
363
+ return (
364
+ screenX + screenW > 0 &&
365
+ screenX < cw &&
366
+ screenY + screenH > 0 &&
367
+ screenY < ch
368
+ )
369
+ }
370
+
371
+ // ── Layout ───────────────────────────────────────────────────────────────
372
+
373
+ async function layout(
374
+ algorithm: LayoutAlgorithm = 'layered',
375
+ options: LayoutOptions = {},
376
+ ): Promise<void> {
377
+ const currentNodes = nodes.peek()
378
+ const currentEdges = edges.peek()
379
+
380
+ const positions = await computeLayout(
381
+ currentNodes,
382
+ currentEdges,
383
+ algorithm,
384
+ options,
385
+ )
386
+
387
+ const animate = options.animate !== false
388
+ const duration = options.animationDuration ?? 300
389
+
390
+ if (!animate) {
391
+ batch(() => {
392
+ nodes.update((nds) =>
393
+ nds.map((node) => {
394
+ const pos = positions.find((p) => p.id === node.id)
395
+ return pos ? { ...node, position: pos.position } : node
396
+ }),
397
+ )
398
+ })
399
+ return
400
+ }
401
+
402
+ // Animated transition — interpolate positions over duration
403
+ const startPositions = new Map(
404
+ currentNodes.map((n) => [n.id, { ...n.position }]),
405
+ )
406
+ const targetPositions = new Map(positions.map((p) => [p.id, p.position]))
407
+
408
+ const startTime = performance.now()
409
+
410
+ const animateFrame = () => {
411
+ const elapsed = performance.now() - startTime
412
+ const t = Math.min(elapsed / duration, 1)
413
+ // Ease-out cubic
414
+ const eased = 1 - (1 - t) ** 3
415
+
416
+ batch(() => {
417
+ nodes.update((nds) =>
418
+ nds.map((node) => {
419
+ const start = startPositions.get(node.id)
420
+ const end = targetPositions.get(node.id)
421
+ if (!start || !end) return node
422
+ return {
423
+ ...node,
424
+ position: {
425
+ x: start.x + (end.x - start.x) * eased,
426
+ y: start.y + (end.y - start.y) * eased,
427
+ },
428
+ }
429
+ }),
430
+ )
431
+ })
432
+
433
+ if (t < 1) requestAnimationFrame(animateFrame)
434
+ }
435
+
436
+ requestAnimationFrame(animateFrame)
437
+ }
438
+
439
+ // ── Batch ────────────────────────────────────────────────────────────────
440
+
441
+ function batchOp(fn: () => void): void {
442
+ batch(fn)
443
+ }
444
+
445
+ // ── Graph queries ────────────────────────────────────────────────────────
446
+
447
+ function getConnectedEdges(nodeId: string): FlowEdge[] {
448
+ return edges
449
+ .peek()
450
+ .filter((e) => e.source === nodeId || e.target === nodeId)
451
+ }
452
+
453
+ function getIncomers(nodeId: string): FlowNode[] {
454
+ const incomingEdges = edges.peek().filter((e) => e.target === nodeId)
455
+ const sourceIds = new Set(incomingEdges.map((e) => e.source))
456
+ return nodes.peek().filter((n) => sourceIds.has(n.id))
457
+ }
458
+
459
+ function getOutgoers(nodeId: string): FlowNode[] {
460
+ const outgoingEdges = edges.peek().filter((e) => e.source === nodeId)
461
+ const targetIds = new Set(outgoingEdges.map((e) => e.target))
462
+ return nodes.peek().filter((n) => targetIds.has(n.id))
463
+ }
464
+
465
+ // ── Listeners ────────────────────────────────────────────────────────────
466
+
467
+ function onConnect(callback: (connection: Connection) => void): () => void {
468
+ connectListeners.add(callback)
469
+ return () => connectListeners.delete(callback)
470
+ }
471
+
472
+ function onNodesChange(
473
+ callback: (changes: NodeChange[]) => void,
474
+ ): () => void {
475
+ nodesChangeListeners.add(callback)
476
+ return () => nodesChangeListeners.delete(callback)
477
+ }
478
+
479
+ function onNodeClick(callback: (node: FlowNode) => void): () => void {
480
+ nodeClickListeners.add(callback)
481
+ return () => nodeClickListeners.delete(callback)
482
+ }
483
+
484
+ function onEdgeClick(callback: (edge: FlowEdge) => void): () => void {
485
+ edgeClickListeners.add(callback)
486
+ return () => edgeClickListeners.delete(callback)
487
+ }
488
+
489
+ function onNodeDragStart(callback: (node: FlowNode) => void): () => void {
490
+ nodeDragStartListeners.add(callback)
491
+ return () => nodeDragStartListeners.delete(callback)
492
+ }
493
+
494
+ function onNodeDragEnd(callback: (node: FlowNode) => void): () => void {
495
+ nodeDragEndListeners.add(callback)
496
+ return () => nodeDragEndListeners.delete(callback)
497
+ }
498
+
499
+ function onNodeDoubleClick(callback: (node: FlowNode) => void): () => void {
500
+ nodeDoubleClickListeners.add(callback)
501
+ return () => nodeDoubleClickListeners.delete(callback)
502
+ }
503
+
504
+ // ── Copy / Paste ────────────────────────────────────────────────────────
505
+
506
+ let clipboard: { nodes: FlowNode[]; edges: FlowEdge[] } | null = null
507
+
508
+ function copySelected(): void {
509
+ const selectedNodeSet = selectedNodeIds.peek()
510
+ if (selectedNodeSet.size === 0) return
511
+
512
+ const copiedNodes = nodes.peek().filter((n) => selectedNodeSet.has(n.id))
513
+ const nodeIdSet = new Set(copiedNodes.map((n) => n.id))
514
+ const copiedEdges = edges
515
+ .peek()
516
+ .filter((e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target))
517
+
518
+ clipboard = { nodes: copiedNodes, edges: copiedEdges }
519
+ }
520
+
521
+ function paste(offset: XYPosition = { x: 50, y: 50 }): void {
522
+ if (!clipboard) return
523
+
524
+ const idMap = new Map<string, string>()
525
+ const newNodes: FlowNode[] = []
526
+
527
+ // Create new nodes with offset positions and new ids
528
+ for (const node of clipboard.nodes) {
529
+ const newId = `${node.id}-copy-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
530
+ idMap.set(node.id, newId)
531
+ newNodes.push({
532
+ ...node,
533
+ id: newId,
534
+ position: {
535
+ x: node.position.x + offset.x,
536
+ y: node.position.y + offset.y,
537
+ },
538
+ })
539
+ }
540
+
541
+ const newEdges: FlowEdge[] = clipboard.edges.map((e) => ({
542
+ ...e,
543
+ id: undefined,
544
+ source: idMap.get(e.source) ?? e.source,
545
+ target: idMap.get(e.target) ?? e.target,
546
+ }))
547
+
548
+ batch(() => {
549
+ for (const node of newNodes) addNode(node)
550
+ for (const edge of newEdges) addEdge(edge)
551
+
552
+ // Select pasted nodes
553
+ selectedNodeIds.set(new Set(newNodes.map((n) => n.id)))
554
+ selectedEdgeIds.set(new Set())
555
+ })
556
+ }
557
+
558
+ // ── Undo / Redo ────────────────────────────────────────────────────────
559
+
560
+ const undoStack: Array<{ nodes: FlowNode[]; edges: FlowEdge[] }> = []
561
+ const redoStack: Array<{ nodes: FlowNode[]; edges: FlowEdge[] }> = []
562
+ const maxHistory = 50
563
+
564
+ function pushHistory(): void {
565
+ undoStack.push({
566
+ nodes: structuredClone(nodes.peek()),
567
+ edges: structuredClone(edges.peek()),
568
+ })
569
+ if (undoStack.length > maxHistory) undoStack.shift()
570
+ redoStack.length = 0
571
+ }
572
+
573
+ function undo(): void {
574
+ const prev = undoStack.pop()
575
+ if (!prev) return
576
+
577
+ redoStack.push({
578
+ nodes: structuredClone(nodes.peek()),
579
+ edges: structuredClone(edges.peek()),
580
+ })
581
+
582
+ batch(() => {
583
+ nodes.set(prev.nodes)
584
+ edges.set(prev.edges)
585
+ clearSelection()
586
+ })
587
+ }
588
+
589
+ function redo(): void {
590
+ const next = redoStack.pop()
591
+ if (!next) return
592
+
593
+ undoStack.push({
594
+ nodes: structuredClone(nodes.peek()),
595
+ edges: structuredClone(edges.peek()),
596
+ })
597
+
598
+ batch(() => {
599
+ nodes.set(next.nodes)
600
+ edges.set(next.edges)
601
+ clearSelection()
602
+ })
603
+ }
604
+
605
+ // ── Multi-node drag ────────────────────────────────────────────────────
606
+
607
+ function moveSelectedNodes(dx: number, dy: number): void {
608
+ const selected = selectedNodeIds.peek()
609
+ if (selected.size === 0) return
610
+
611
+ nodes.update((nds) =>
612
+ nds.map((n) => {
613
+ if (!selected.has(n.id)) return n
614
+ return {
615
+ ...n,
616
+ position: {
617
+ x: n.position.x + dx,
618
+ y: n.position.y + dy,
619
+ },
620
+ }
621
+ }),
622
+ )
623
+ }
624
+
625
+ // ── Helper lines (snap guides) ─────────────────────────────────────────
626
+
627
+ function getSnapLines(
628
+ dragNodeId: string,
629
+ position: XYPosition,
630
+ threshold = 5,
631
+ ): { x: number | null; y: number | null; snappedPosition: XYPosition } {
632
+ const dragNode = getNode(dragNodeId)
633
+ if (!dragNode) return { x: null, y: null, snappedPosition: position }
634
+
635
+ const w = dragNode.width ?? 150
636
+ const h = dragNode.height ?? 40
637
+ const dragCenterX = position.x + w / 2
638
+ const dragCenterY = position.y + h / 2
639
+
640
+ let snapX: number | null = null
641
+ let snapY: number | null = null
642
+ let snappedX = position.x
643
+ let snappedY = position.y
644
+
645
+ for (const node of nodes.peek()) {
646
+ if (node.id === dragNodeId) continue
647
+ const nw = node.width ?? 150
648
+ const nh = node.height ?? 40
649
+ const nodeCenterX = node.position.x + nw / 2
650
+ const nodeCenterY = node.position.y + nh / 2
651
+
652
+ // Snap to center X
653
+ if (Math.abs(dragCenterX - nodeCenterX) < threshold) {
654
+ snapX = nodeCenterX
655
+ snappedX = nodeCenterX - w / 2
656
+ }
657
+ // Snap to left edge
658
+ if (Math.abs(position.x - node.position.x) < threshold) {
659
+ snapX = node.position.x
660
+ snappedX = node.position.x
661
+ }
662
+ // Snap to right edge
663
+ if (Math.abs(position.x + w - (node.position.x + nw)) < threshold) {
664
+ snapX = node.position.x + nw
665
+ snappedX = node.position.x + nw - w
666
+ }
667
+
668
+ // Snap to center Y
669
+ if (Math.abs(dragCenterY - nodeCenterY) < threshold) {
670
+ snapY = nodeCenterY
671
+ snappedY = nodeCenterY - h / 2
672
+ }
673
+ // Snap to top edge
674
+ if (Math.abs(position.y - node.position.y) < threshold) {
675
+ snapY = node.position.y
676
+ snappedY = node.position.y
677
+ }
678
+ // Snap to bottom edge
679
+ if (Math.abs(position.y + h - (node.position.y + nh)) < threshold) {
680
+ snapY = node.position.y + nh
681
+ snappedY = node.position.y + nh - h
682
+ }
683
+ }
684
+
685
+ return { x: snapX, y: snapY, snappedPosition: { x: snappedX, y: snappedY } }
686
+ }
687
+
688
+ // ── Sub-flows / Groups ──────────────────────────────────────────────────
689
+
690
+ function getChildNodes(parentId: string): FlowNode[] {
691
+ return nodes.peek().filter((n) => n.parentId === parentId)
692
+ }
693
+
694
+ function getAbsolutePosition(nodeId: string): XYPosition {
695
+ const node = getNode(nodeId)
696
+ if (!node) return { x: 0, y: 0 }
697
+
698
+ if (node.parentId) {
699
+ const parentPos = getAbsolutePosition(node.parentId)
700
+ return {
701
+ x: parentPos.x + node.position.x,
702
+ y: parentPos.y + node.position.y,
703
+ }
704
+ }
705
+
706
+ return node.position
707
+ }
708
+
709
+ // ── Edge reconnecting ──────────────────────────────────────────────────
710
+
711
+ function reconnectEdge(
712
+ targetEdgeId: string,
713
+ newConnection: {
714
+ source?: string
715
+ target?: string
716
+ sourceHandle?: string
717
+ targetHandle?: string
718
+ },
719
+ ): void {
720
+ edges.update((eds) =>
721
+ eds.map((e) => {
722
+ if (e.id !== targetEdgeId) return e
723
+ return {
724
+ ...e,
725
+ source: newConnection.source ?? e.source,
726
+ target: newConnection.target ?? e.target,
727
+ sourceHandle: newConnection.sourceHandle ?? e.sourceHandle,
728
+ targetHandle: newConnection.targetHandle ?? e.targetHandle,
729
+ }
730
+ }),
731
+ )
732
+ }
733
+
734
+ // ── Edge waypoints ──────────────────────────────────────────────────────
735
+
736
+ function addEdgeWaypoint(
737
+ edgeIdentifier: string,
738
+ point: XYPosition,
739
+ index?: number,
740
+ ): void {
741
+ edges.update((eds) =>
742
+ eds.map((e) => {
743
+ if (e.id !== edgeIdentifier) return e
744
+ const waypoints = [...(e.waypoints ?? [])]
745
+ if (index !== undefined) {
746
+ waypoints.splice(index, 0, point)
747
+ } else {
748
+ waypoints.push(point)
749
+ }
750
+ return { ...e, waypoints }
751
+ }),
752
+ )
753
+ }
754
+
755
+ function removeEdgeWaypoint(edgeIdentifier: string, index: number): void {
756
+ edges.update((eds) =>
757
+ eds.map((e) => {
758
+ if (e.id !== edgeIdentifier) return e
759
+ const waypoints = [...(e.waypoints ?? [])]
760
+ waypoints.splice(index, 1)
761
+ return { ...e, waypoints: waypoints.length > 0 ? waypoints : undefined }
762
+ }),
763
+ )
764
+ }
765
+
766
+ function updateEdgeWaypoint(
767
+ edgeIdentifier: string,
768
+ index: number,
769
+ point: XYPosition,
770
+ ): void {
771
+ edges.update((eds) =>
772
+ eds.map((e) => {
773
+ if (e.id !== edgeIdentifier) return e
774
+ const waypoints = [...(e.waypoints ?? [])]
775
+ if (index >= 0 && index < waypoints.length) {
776
+ waypoints[index] = point
777
+ }
778
+ return { ...e, waypoints }
779
+ }),
780
+ )
781
+ }
782
+
783
+ // ── Proximity connect ───────────────────────────────────────────────────
784
+
785
+ function getProximityConnection(
786
+ nodeId: string,
787
+ threshold = 50,
788
+ ): Connection | null {
789
+ const node = getNode(nodeId)
790
+ if (!node) return null
791
+
792
+ const w = node.width ?? 150
793
+ const h = node.height ?? 40
794
+ const centerX = node.position.x + w / 2
795
+ const centerY = node.position.y + h / 2
796
+
797
+ let closest: { nodeId: string; dist: number } | null = null
798
+
799
+ for (const other of nodes.peek()) {
800
+ if (other.id === nodeId) continue
801
+ // Skip if already connected
802
+ const alreadyConnected = edges
803
+ .peek()
804
+ .some(
805
+ (e) =>
806
+ (e.source === nodeId && e.target === other.id) ||
807
+ (e.source === other.id && e.target === nodeId),
808
+ )
809
+ if (alreadyConnected) continue
810
+
811
+ const ow = other.width ?? 150
812
+ const oh = other.height ?? 40
813
+ const ocx = other.position.x + ow / 2
814
+ const ocy = other.position.y + oh / 2
815
+ const dist = Math.hypot(centerX - ocx, centerY - ocy)
816
+
817
+ if (dist < threshold && (!closest || dist < closest.dist)) {
818
+ closest = { nodeId: other.id, dist }
819
+ }
820
+ }
821
+
822
+ if (!closest) return null
823
+
824
+ const connection: Connection = {
825
+ source: nodeId,
826
+ target: closest.nodeId,
827
+ }
828
+
829
+ return isValidConnection(connection) ? connection : null
830
+ }
831
+
832
+ // ── Collision detection ────────────────────────────────────────────────
833
+
834
+ function getOverlappingNodes(nodeId: string): FlowNode[] {
835
+ const node = getNode(nodeId)
836
+ if (!node) return []
837
+
838
+ const w = node.width ?? 150
839
+ const h = node.height ?? 40
840
+ const ax1 = node.position.x
841
+ const ay1 = node.position.y
842
+ const ax2 = ax1 + w
843
+ const ay2 = ay1 + h
844
+
845
+ return nodes.peek().filter((other) => {
846
+ if (other.id === nodeId) return false
847
+ const ow = other.width ?? 150
848
+ const oh = other.height ?? 40
849
+ const bx1 = other.position.x
850
+ const by1 = other.position.y
851
+ const bx2 = bx1 + ow
852
+ const by2 = by1 + oh
853
+
854
+ return ax1 < bx2 && ax2 > bx1 && ay1 < by2 && ay2 > by1
855
+ })
856
+ }
857
+
858
+ function resolveCollisions(nodeId: string, spacing = 10): void {
859
+ const overlapping = getOverlappingNodes(nodeId)
860
+ if (overlapping.length === 0) return
861
+
862
+ const node = getNode(nodeId)
863
+ if (!node) return
864
+
865
+ const w = node.width ?? 150
866
+ const h = node.height ?? 40
867
+
868
+ for (const other of overlapping) {
869
+ const ow = other.width ?? 150
870
+ const oh = other.height ?? 40
871
+
872
+ // Calculate overlap amounts
873
+ const overlapX = Math.min(
874
+ node.position.x + w - other.position.x,
875
+ other.position.x + ow - node.position.x,
876
+ )
877
+ const overlapY = Math.min(
878
+ node.position.y + h - other.position.y,
879
+ other.position.y + oh - node.position.y,
880
+ )
881
+
882
+ // Push in the direction of least overlap
883
+ if (overlapX < overlapY) {
884
+ const dx =
885
+ node.position.x < other.position.x
886
+ ? -(overlapX + spacing) / 2
887
+ : (overlapX + spacing) / 2
888
+ updateNodePosition(other.id, {
889
+ x: other.position.x - dx,
890
+ y: other.position.y,
891
+ })
892
+ } else {
893
+ const dy =
894
+ node.position.y < other.position.y
895
+ ? -(overlapY + spacing) / 2
896
+ : (overlapY + spacing) / 2
897
+ updateNodePosition(other.id, {
898
+ x: other.position.x,
899
+ y: other.position.y - dy,
900
+ })
901
+ }
902
+ }
903
+ }
904
+
905
+ // ── Node extent (drag boundaries) ──────────────────────────────────────
906
+
907
+ function setNodeExtent(
908
+ extent: [[number, number], [number, number]] | null,
909
+ ): void {
910
+ nodeExtent = extent
911
+ }
912
+
913
+ let nodeExtent: [[number, number], [number, number]] | null =
914
+ config.nodeExtent ?? null
915
+
916
+ function clampToExtent(
917
+ position: XYPosition,
918
+ nodeWidth = 150,
919
+ nodeHeight = 40,
920
+ ): XYPosition {
921
+ if (!nodeExtent) return position
922
+ return {
923
+ x: Math.min(
924
+ Math.max(position.x, nodeExtent[0][0]),
925
+ nodeExtent[1][0] - nodeWidth,
926
+ ),
927
+ y: Math.min(
928
+ Math.max(position.y, nodeExtent[0][1]),
929
+ nodeExtent[1][1] - nodeHeight,
930
+ ),
931
+ }
932
+ }
933
+
934
+ // ── Custom edge types ──────────────────────────────────────────────────
935
+ // Custom edge rendering is handled in the Flow component via edgeTypes prop.
936
+ // The flow instance provides the data; rendering is delegated to components.
937
+
938
+ // ── Search / Filter ─────────────────────────────────────────────────────
939
+
940
+ function findNodes(predicate: (node: FlowNode) => boolean): FlowNode[] {
941
+ return nodes.peek().filter(predicate)
942
+ }
943
+
944
+ function searchNodes(query: string): FlowNode[] {
945
+ const q = query.toLowerCase()
946
+ return nodes.peek().filter((n) => {
947
+ const label = (n.data?.label as string) ?? n.id
948
+ return label.toLowerCase().includes(q)
949
+ })
950
+ }
951
+
952
+ function focusNode(nodeId: string, focusZoom?: number): void {
953
+ const node = getNode(nodeId)
954
+ if (!node) return
955
+
956
+ const w = node.width ?? 150
957
+ const h = node.height ?? 40
958
+ const centerX = node.position.x + w / 2
959
+ const centerY = node.position.y + h / 2
960
+ const z = focusZoom ?? viewport.peek().zoom
961
+ const { width: cw, height: ch } = containerSize.peek()
962
+
963
+ animateViewport({
964
+ x: -centerX * z + cw / 2,
965
+ y: -centerY * z + ch / 2,
966
+ zoom: z,
967
+ })
968
+
969
+ // Select the focused node
970
+ selectNode(nodeId)
971
+ }
972
+
973
+ // ── Export / Import ────────────────────────────────────────────────────
974
+
975
+ function toJSON(): {
976
+ nodes: FlowNode[]
977
+ edges: FlowEdge[]
978
+ viewport: { x: number; y: number; zoom: number }
979
+ } {
980
+ return {
981
+ nodes: structuredClone(nodes.peek()),
982
+ edges: structuredClone(edges.peek()),
983
+ viewport: { ...viewport.peek() },
984
+ }
985
+ }
986
+
987
+ function fromJSON(data: {
988
+ nodes: FlowNode[]
989
+ edges: FlowEdge[]
990
+ viewport?: { x: number; y: number; zoom: number }
991
+ }): void {
992
+ batch(() => {
993
+ nodes.set(data.nodes)
994
+ edges.set(
995
+ data.edges.map((e) => ({
996
+ ...e,
997
+ id: e.id ?? edgeId(e),
998
+ type: e.type ?? defaultEdgeType,
999
+ })),
1000
+ )
1001
+ if (data.viewport) viewport.set(data.viewport)
1002
+ clearSelection()
1003
+ })
1004
+ }
1005
+
1006
+ // ── Viewport animation ─────────────────────────────────────────────────
1007
+
1008
+ function animateViewport(
1009
+ target: Partial<{ x: number; y: number; zoom: number }>,
1010
+ duration = 300,
1011
+ ): void {
1012
+ const start = { ...viewport.peek() }
1013
+ const end = {
1014
+ x: target.x ?? start.x,
1015
+ y: target.y ?? start.y,
1016
+ zoom: target.zoom ?? start.zoom,
1017
+ }
1018
+ const startTime = performance.now()
1019
+
1020
+ const frame = () => {
1021
+ const elapsed = performance.now() - startTime
1022
+ const t = Math.min(elapsed / duration, 1)
1023
+ const eased = 1 - (1 - t) ** 3 // ease-out cubic
1024
+
1025
+ viewport.set({
1026
+ x: start.x + (end.x - start.x) * eased,
1027
+ y: start.y + (end.y - start.y) * eased,
1028
+ zoom: start.zoom + (end.zoom - start.zoom) * eased,
1029
+ })
1030
+
1031
+ if (t < 1) requestAnimationFrame(frame)
1032
+ }
1033
+
1034
+ requestAnimationFrame(frame)
1035
+ }
1036
+
1037
+ // ── Dispose ──────────────────────────────────────────────────────────────
1038
+
1039
+ function dispose(): void {
1040
+ connectListeners.clear()
1041
+ nodesChangeListeners.clear()
1042
+ nodeClickListeners.clear()
1043
+ edgeClickListeners.clear()
1044
+ nodeDragStartListeners.clear()
1045
+ nodeDragEndListeners.clear()
1046
+ nodeDoubleClickListeners.clear()
1047
+ }
1048
+
1049
+ // ── Initial fitView ──────────────────────────────────────────────────────
1050
+
1051
+ if (config.fitView) {
1052
+ fitView()
1053
+ }
1054
+
1055
+ return {
1056
+ nodes,
1057
+ edges,
1058
+ viewport,
1059
+ zoom,
1060
+ containerSize,
1061
+ selectedNodes,
1062
+ selectedEdges,
1063
+ getNode,
1064
+ addNode,
1065
+ removeNode,
1066
+ updateNode,
1067
+ updateNodePosition,
1068
+ getEdge,
1069
+ addEdge,
1070
+ removeEdge,
1071
+ isValidConnection,
1072
+ selectNode,
1073
+ deselectNode,
1074
+ selectEdge,
1075
+ clearSelection,
1076
+ selectAll,
1077
+ deleteSelected,
1078
+ fitView,
1079
+ zoomTo,
1080
+ zoomIn,
1081
+ zoomOut,
1082
+ panTo,
1083
+ isNodeVisible,
1084
+ layout,
1085
+ batch: batchOp,
1086
+ getConnectedEdges,
1087
+ getIncomers,
1088
+ getOutgoers,
1089
+ onConnect,
1090
+ onNodesChange,
1091
+ onNodeClick,
1092
+ onEdgeClick,
1093
+ onNodeDragStart,
1094
+ onNodeDragEnd,
1095
+ onNodeDoubleClick,
1096
+ /** @internal — used by Flow component to emit events */
1097
+ _emit: {
1098
+ nodeDragStart: (node: FlowNode) => {
1099
+ for (const cb of nodeDragStartListeners) cb(node)
1100
+ },
1101
+ nodeDragEnd: (node: FlowNode) => {
1102
+ for (const cb of nodeDragEndListeners) cb(node)
1103
+ },
1104
+ nodeDoubleClick: (node: FlowNode) => {
1105
+ for (const cb of nodeDoubleClickListeners) cb(node)
1106
+ },
1107
+ nodeClick: (node: FlowNode) => {
1108
+ for (const cb of nodeClickListeners) cb(node)
1109
+ },
1110
+ edgeClick: (edge: FlowEdge) => {
1111
+ for (const cb of edgeClickListeners) cb(edge)
1112
+ },
1113
+ },
1114
+ copySelected,
1115
+ paste,
1116
+ pushHistory,
1117
+ undo,
1118
+ redo,
1119
+ moveSelectedNodes,
1120
+ getSnapLines,
1121
+ getChildNodes,
1122
+ getAbsolutePosition,
1123
+ addEdgeWaypoint,
1124
+ removeEdgeWaypoint,
1125
+ updateEdgeWaypoint,
1126
+ reconnectEdge,
1127
+ getProximityConnection,
1128
+ getOverlappingNodes,
1129
+ resolveCollisions,
1130
+ setNodeExtent,
1131
+ clampToExtent,
1132
+ findNodes,
1133
+ searchNodes,
1134
+ focusNode,
1135
+ toJSON,
1136
+ fromJSON,
1137
+ animateViewport,
1138
+ config,
1139
+ dispose,
1140
+ }
1141
+ }