reactflow-edge-routing 0.1.1

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Node collision resolution — pushes overlapping nodes apart iteratively.
3
+ *
4
+ * Supports arbitrarily nested subflows: resolves innermost siblings first
5
+ * (bottom-up), so parent group sizes are always based on already-resolved
6
+ * child positions.
7
+ */
8
+
9
+ import type { Node } from '@xyflow/react';
10
+
11
+ const DEFAULT_NODE_WIDTH = 150;
12
+ const DEFAULT_NODE_HEIGHT = 50;
13
+
14
+ export type CollisionAlgorithmOptions = {
15
+ maxIterations?: number;
16
+ overlapThreshold?: number;
17
+ margin?: number;
18
+ };
19
+
20
+ export type CollisionAlgorithm = (
21
+ nodes: Node[],
22
+ options?: CollisionAlgorithmOptions,
23
+ ) => Node[];
24
+
25
+ type Box = {
26
+ x: number;
27
+ y: number;
28
+ width: number;
29
+ height: number;
30
+ moved: boolean;
31
+ node: Node;
32
+ };
33
+
34
+ // ── Size helpers ─────────────────────────────────────────────────
35
+
36
+ function getNodeSizeSimple(node: Node): { width: number; height: number } {
37
+ const style = node.style as { width?: number; height?: number } | undefined;
38
+ const w = node.measured?.width ?? node.width ?? style?.width ?? DEFAULT_NODE_WIDTH;
39
+ const h = node.measured?.height ?? node.height ?? style?.height ?? DEFAULT_NODE_HEIGHT;
40
+ return { width: Number(w) || DEFAULT_NODE_WIDTH, height: Number(h) || DEFAULT_NODE_HEIGHT };
41
+ }
42
+
43
+ /**
44
+ * Compute the actual size of a group node from its children, recursively.
45
+ * Works for any nesting depth.
46
+ */
47
+ function computeGroupSize(
48
+ nodeId: string,
49
+ childrenByParent: Map<string, Node[]>,
50
+ nodeById: Map<string, Node>
51
+ ): { width: number; height: number } {
52
+ const children = childrenByParent.get(nodeId);
53
+ if (!children || children.length === 0) {
54
+ const node = nodeById.get(nodeId);
55
+ return node ? getNodeSizeSimple(node) : { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT };
56
+ }
57
+
58
+ let maxRight = 0;
59
+ let maxBottom = 0;
60
+
61
+ for (const child of children) {
62
+ const childSize = child.type === "group"
63
+ ? computeGroupSize(child.id, childrenByParent, nodeById)
64
+ : getNodeSizeSimple(child);
65
+ const right = child.position.x + childSize.width;
66
+ const bottom = child.position.y + childSize.height;
67
+ if (right > maxRight) maxRight = right;
68
+ if (bottom > maxBottom) maxBottom = bottom;
69
+ }
70
+
71
+ const padding = 20;
72
+ return {
73
+ width: maxRight + padding,
74
+ height: maxBottom + padding,
75
+ };
76
+ }
77
+
78
+ function getNodeSize(
79
+ node: Node,
80
+ childrenByParent: Map<string, Node[]>,
81
+ nodeById: Map<string, Node>
82
+ ): { width: number; height: number } {
83
+ if (node.measured?.width && node.measured?.height) {
84
+ return { width: node.measured.width, height: node.measured.height };
85
+ }
86
+ if (node.type === "group") {
87
+ return computeGroupSize(node.id, childrenByParent, nodeById);
88
+ }
89
+ return getNodeSizeSimple(node);
90
+ }
91
+
92
+ function buildBoxes(
93
+ nodes: Node[],
94
+ margin: number,
95
+ childrenByParent: Map<string, Node[]>,
96
+ nodeById: Map<string, Node>
97
+ ): Box[] {
98
+ return nodes.map((node) => {
99
+ const { width, height } = getNodeSize(node, childrenByParent, nodeById);
100
+ return {
101
+ x: node.position.x - margin,
102
+ y: node.position.y - margin,
103
+ width: width + margin * 2,
104
+ height: height + margin * 2,
105
+ node,
106
+ moved: false,
107
+ };
108
+ });
109
+ }
110
+
111
+ // ── Core resolution ──────────────────────────────────────────────
112
+
113
+ function resolveBoxes(boxes: Box[], maxIter: number, threshold: number): void {
114
+ for (let iter = 0; iter <= maxIter; iter++) {
115
+ let moved = false;
116
+ for (let i = 0; i < boxes.length; i++) {
117
+ for (let j = i + 1; j < boxes.length; j++) {
118
+ const A = boxes[i];
119
+ const B = boxes[j];
120
+ const dx = (A.x + A.width * 0.5) - (B.x + B.width * 0.5);
121
+ const dy = (A.y + A.height * 0.5) - (B.y + B.height * 0.5);
122
+ const px = (A.width + B.width) * 0.5 - Math.abs(dx);
123
+ const py = (A.height + B.height) * 0.5 - Math.abs(dy);
124
+
125
+ if (px > threshold && py > threshold) {
126
+ A.moved = B.moved = moved = true;
127
+ if (px < py) {
128
+ const half = (px / 2) * (dx > 0 ? 1 : -1);
129
+ A.x += half; B.x -= half;
130
+ } else {
131
+ const half = (py / 2) * (dy > 0 ? 1 : -1);
132
+ A.y += half; B.y -= half;
133
+ }
134
+ }
135
+ }
136
+ }
137
+ if (!moved) break;
138
+ }
139
+ }
140
+
141
+ function getDepth(nodeId: string | undefined, nodeById: Map<string, Node>): number {
142
+ let depth = 0;
143
+ let current = nodeId ? nodeById.get(nodeId) : undefined;
144
+ while (current?.parentId) {
145
+ depth++;
146
+ current = nodeById.get(current.parentId);
147
+ }
148
+ return depth;
149
+ }
150
+
151
+ /**
152
+ * Pushes overlapping nodes apart along the axis with smallest overlap.
153
+ *
154
+ * Strategy:
155
+ * 1. Group ALL nodes (including groups) by parentId
156
+ * 2. Sort groups by depth (deepest first = bottom-up)
157
+ * 3. Resolve innermost siblings first, update their positions, then
158
+ * resolve outer siblings — so parent group sizes are always computed
159
+ * from already-resolved child positions.
160
+ */
161
+ export const resolveCollisions: CollisionAlgorithm = (
162
+ nodes,
163
+ options = {},
164
+ ) => {
165
+ if (nodes.length < 2) return nodes;
166
+
167
+ const maxIter = options.maxIterations ?? 50;
168
+ const threshold = options.overlapThreshold ?? 0.5;
169
+ const margin = options.margin ?? 20;
170
+
171
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
172
+
173
+ const childrenByParent = new Map<string, Node[]>();
174
+ for (const node of nodes) {
175
+ const key = node.parentId ?? "__root__";
176
+ if (!childrenByParent.has(key)) childrenByParent.set(key, []);
177
+ childrenByParent.get(key)!.push(node);
178
+ }
179
+
180
+ // Sort parent groups by depth (deepest first) so inner siblings
181
+ // are resolved before outer ones
182
+ const parentKeys = [...childrenByParent.keys()];
183
+ parentKeys.sort((a, b) => {
184
+ const depthA = a === "__root__" ? -1 : getDepth(a, nodeById);
185
+ const depthB = b === "__root__" ? -1 : getDepth(b, nodeById);
186
+ return depthB - depthA;
187
+ });
188
+
189
+ const movedNodes = new Map<string, { x: number; y: number }>();
190
+
191
+ for (const parentKey of parentKeys) {
192
+ const siblings = childrenByParent.get(parentKey)!;
193
+ if (siblings.length < 2) continue;
194
+
195
+ const boxes = buildBoxes(siblings, margin, childrenByParent, nodeById);
196
+ resolveBoxes(boxes, maxIter, threshold);
197
+
198
+ for (const box of boxes) {
199
+ if (box.moved) {
200
+ const newPos = { x: box.x + margin, y: box.y + margin };
201
+ movedNodes.set(box.node.id, newPos);
202
+ // Update position in-place so parent size computations in
203
+ // subsequent (shallower) iterations see resolved positions
204
+ box.node.position = newPos;
205
+ }
206
+ }
207
+ }
208
+
209
+ if (movedNodes.size === 0) return nodes;
210
+
211
+ return nodes.map((node) => {
212
+ const pos = movedNodes.get(node.id);
213
+ return pos ? { ...node, position: pos } : node;
214
+ });
215
+ };