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,1050 @@
1
+ /**
2
+ * Core edge routing engine using libavoid-js (pure TypeScript).
3
+ *
4
+ * Pins are placed at exact SVG-computed anchor positions per handle.
5
+ * Edges use pin-based ConnEnd(shapeRef, pinId) so libavoid knows which
6
+ * shape an edge belongs to and won't route through it.
7
+ */
8
+
9
+ import {
10
+ Router as AvoidRouter,
11
+ Point as AvoidPoint,
12
+ Rectangle as AvoidRectangle,
13
+ ShapeRef as AvoidShapeRef,
14
+ ShapeConnectionPin as AvoidShapeConnectionPin,
15
+ ConnEnd as AvoidConnEnd,
16
+ ConnRef as AvoidConnRef,
17
+ Checkpoint as AvoidCheckpoint,
18
+ ConnectorCrossings,
19
+ AStarPath,
20
+ OrthogonalRouting,
21
+ PolyLineRouting,
22
+ ConnType_Orthogonal,
23
+ ConnType_PolyLine,
24
+ ConnDirUp,
25
+ ConnDirDown,
26
+ ConnDirLeft,
27
+ ConnDirRight,
28
+ // Routing parameters (all 9 from libavoid C++)
29
+ segmentPenalty as segmentPenaltyParam,
30
+ anglePenalty as anglePenaltyParam,
31
+ crossingPenalty as crossingPenaltyParam,
32
+ clusterCrossingPenalty as clusterCrossingPenaltyParam,
33
+ fixedSharedPathPenalty as fixedSharedPathPenaltyParam,
34
+ portDirectionPenalty as portDirectionPenaltyParam,
35
+ shapeBufferDistance as shapeBufferDistanceParam,
36
+ idealNudgingDistance as idealNudgingDistanceParam,
37
+ reverseDirectionPenalty as reverseDirectionPenaltyParam,
38
+ // Routing options (all 7 from libavoid C++)
39
+ nudgeOrthogonalSegmentsConnectedToShapes as nudgeOrthogonalSegmentsConnectedToShapesOpt,
40
+ nudgeSharedPathsWithCommonEndPoint as nudgeSharedPathsWithCommonEndPointOpt,
41
+ performUnifyingNudgingPreprocessingStep as performUnifyingNudgingPreprocessingStepOpt,
42
+ nudgeOrthogonalTouchingColinearSegments as nudgeOrthogonalTouchingColinearSegmentsOpt,
43
+ improveHyperedgeRoutesMovingJunctions as improveHyperedgeRoutesMovingJunctionsOpt,
44
+ penaliseOrthogonalSharedPathsAtConnEnds as penaliseOrthogonalSharedPathsAtConnEndsOpt,
45
+ improveHyperedgeRoutesMovingAddingAndDeletingJunctions as improveHyperedgeRoutesMovingAddingAndDeletingJunctionsOpt,
46
+ generateStaticOrthogonalVisGraph,
47
+ improveOrthogonalRoutes,
48
+ vertexVisibility,
49
+ } from "obstacle-router";
50
+
51
+ // ---- Types ----
52
+
53
+ export type AvoidRoute = {
54
+ path: string;
55
+ labelX: number;
56
+ labelY: number;
57
+ sourceX: number;
58
+ sourceY: number;
59
+ targetX: number;
60
+ targetY: number;
61
+ };
62
+
63
+ export type ConnectorType = "orthogonal" | "polyline" | "bezier";
64
+
65
+ export type AvoidRouterOptions = {
66
+ // --- libavoid routing parameters (numeric penalties/distances) ---
67
+ /** Distance buffer added around shapes when routing. Default: 8 */
68
+ shapeBufferDistance?: number;
69
+ /** Ideal distance for nudging apart overlapping segments. Default: 10 */
70
+ idealNudgingDistance?: number;
71
+ /** Penalty for each segment beyond the first. MUST be >0 for nudging to work. Default: 10 */
72
+ segmentPenalty?: number;
73
+ /** Penalty for tight bends (polyline routing). Default: 0 */
74
+ anglePenalty?: number;
75
+ /** Penalty for crossing other connectors. EXPERIMENTAL. Default: 0 */
76
+ crossingPenalty?: number;
77
+ /** Penalty for crossing cluster boundaries. EXPERIMENTAL. Default: 0 */
78
+ clusterCrossingPenalty?: number;
79
+ /** Penalty for shared paths with fixed connectors. EXPERIMENTAL. Default: 0 */
80
+ fixedSharedPathPenalty?: number;
81
+ /** Penalty for port selection when other end isn't in visibility cone. EXPERIMENTAL. Default: 0 */
82
+ portDirectionPenalty?: number;
83
+ /** Penalty when connector travels opposite direction from destination. Default: 0 */
84
+ reverseDirectionPenalty?: number;
85
+
86
+ // --- libavoid routing options (boolean flags) ---
87
+ /** Nudge final segments attached to shapes. Default: true */
88
+ nudgeOrthogonalSegmentsConnectedToShapes?: boolean;
89
+ /** Nudge intermediate segments at common endpoints. Default: true */
90
+ nudgeSharedPathsWithCommonEndPoint?: boolean;
91
+ /** Unify and center segments before nudging (better quality, slower). Default: true */
92
+ performUnifyingNudgingPreprocessingStep?: boolean;
93
+ /** Nudge colinear segments touching at ends apart. Default: false */
94
+ nudgeOrthogonalTouchingColinearSegments?: boolean;
95
+ /** Improve hyperedge routes by moving junctions. Default: true */
96
+ improveHyperedgeRoutesMovingJunctions?: boolean;
97
+ /** Penalize shared orthogonal paths at common junctions/pins. EXPERIMENTAL. Default: false */
98
+ penaliseOrthogonalSharedPathsAtConnEnds?: boolean;
99
+ /** Improve hyperedges by adding/removing junctions. Default: false */
100
+ improveHyperedgeRoutesMovingAddingAndDeletingJunctions?: boolean;
101
+
102
+ // --- Connector settings ---
103
+ /** Connector routing type: "orthogonal" (default), "polyline", or "bezier". */
104
+ connectorType?: ConnectorType;
105
+ /** If true, connectors try to avoid crossings (longer paths). Default: false */
106
+ hateCrossings?: boolean;
107
+ /** Inside offset (px) for pins — pushes connector start inside shape boundary. Default: 0 */
108
+ pinInsideOffset?: number;
109
+
110
+ // --- Custom post-processing options ---
111
+ /** Spacing (px) between edges at shared handles. Default: same as idealNudgingDistance */
112
+ handleNudgingDistance?: number;
113
+ /** Corner radius for orthogonal path rendering. Default: 0 */
114
+ edgeRounding?: number;
115
+ /** Snap waypoints to grid. Default: 0 (no grid) */
116
+ diagramGridSize?: number;
117
+ /** When true, edges spread out along the node border near handles (pin-based). When false, edges converge to exact handle point. Default: true */
118
+ shouldSplitEdgesNearHandle?: boolean;
119
+ /** Length (px) of the stub segment when shouldSplitEdgesNearHandle is off. Default: 20 */
120
+ stubSize?: number;
121
+ /** Auto-select best connection side based on relative node positions. Default: true */
122
+ autoBestSideConnection?: boolean;
123
+ /** Debounce delay for routing updates (ms). Default: 0 */
124
+ debounceMs?: number;
125
+ };
126
+
127
+ export type HandlePosition = "left" | "right" | "top" | "bottom";
128
+
129
+ /** Pin at exact SVG anchor position (proportional 0-1 within the node) */
130
+ export type HandlePin = {
131
+ handleId: string;
132
+ /** 0-1 proportion from left edge of node */
133
+ xPct: number;
134
+ /** 0-1 proportion from top edge of node */
135
+ yPct: number;
136
+ /** Which side the handle is on — determines connection direction */
137
+ side: HandlePosition;
138
+ /** Optional connection cost for this pin. Lower cost pins are preferred. */
139
+ cost?: number;
140
+ };
141
+
142
+ export type FlowNode = {
143
+ id: string;
144
+ position: { x: number; y: number };
145
+ width?: number;
146
+ height?: number;
147
+ measured?: { width?: number; height?: number };
148
+ style?: { width?: number; height?: number };
149
+ type?: string;
150
+ parentId?: string;
151
+ sourcePosition?: string;
152
+ targetPosition?: string;
153
+ data?: Record<string, unknown>;
154
+ /** Pre-computed handle pins at exact SVG anchor positions (set by main thread) */
155
+ _handlePins?: HandlePin[];
156
+ /** Extra height to add to obstacle (for label + data area below shape) */
157
+ _extraHeight?: number;
158
+ [key: string]: unknown;
159
+ };
160
+
161
+ export type FlowEdge = {
162
+ id: string;
163
+ source: string;
164
+ target: string;
165
+ sourceHandle?: string | null;
166
+ targetHandle?: string | null;
167
+ type?: string;
168
+ /** Optional checkpoints (waypoints) the edge must pass through */
169
+ checkpoints?: { x: number; y: number }[];
170
+ [key: string]: unknown;
171
+ };
172
+
173
+ /** Create a Router with late-bound helpers wired up (breaks circular deps in libavoid-js). */
174
+ function createRouter(flags: number): AvoidRouter {
175
+ const router = new AvoidRouter(flags);
176
+ (router as any)._generateStaticOrthogonalVisGraph = generateStaticOrthogonalVisGraph;
177
+ (router as any)._improveOrthogonalRoutes = improveOrthogonalRoutes;
178
+ (router as any)._ConnectorCrossings = ConnectorCrossings;
179
+ (router as any)._AStarPath = AStarPath;
180
+ (router as any)._vertexVisibility = vertexVisibility;
181
+ return router;
182
+ }
183
+
184
+ /** Get the libavoid ConnType for a connector type string. */
185
+ function getConnType(connectorType: ConnectorType | undefined): number {
186
+ switch (connectorType) {
187
+ case "polyline": return ConnType_PolyLine;
188
+ case "orthogonal":
189
+ case "bezier": // bezier uses orthogonal routing, then post-processes to curves
190
+ default: return ConnType_Orthogonal;
191
+ }
192
+ }
193
+
194
+ /** Get the Router flags for the connector type. */
195
+ function getRouterFlags(connectorType: ConnectorType | undefined): number {
196
+ switch (connectorType) {
197
+ case "polyline": return PolyLineRouting | OrthogonalRouting;
198
+ default: return OrthogonalRouting;
199
+ }
200
+ }
201
+
202
+ // ---- Geometry ----
203
+
204
+ export class Geometry {
205
+ static getNodeBounds(node: FlowNode): { x: number; y: number; w: number; h: number } {
206
+ const x = node.position?.x ?? 0;
207
+ const y = node.position?.y ?? 0;
208
+ const w = Number((node.measured?.width ?? node.width ?? (node.style as { width?: number })?.width) ?? 150);
209
+ const h = Number((node.measured?.height ?? node.height ?? (node.style as { height?: number })?.height) ?? 50);
210
+ const extra = (node._extraHeight as number) ?? 0;
211
+ return { x, y, w, h: h + extra };
212
+ }
213
+
214
+ static getNodeBoundsAbsolute(
215
+ node: FlowNode,
216
+ nodeById: Map<string, FlowNode>
217
+ ): { x: number; y: number; w: number; h: number } {
218
+ const b = Geometry.getNodeBounds(node);
219
+ let current: FlowNode | undefined = node;
220
+ while (current?.parentId) {
221
+ const parent = nodeById.get(current.parentId);
222
+ if (!parent) break;
223
+ b.x += parent.position?.x ?? 0;
224
+ b.y += parent.position?.y ?? 0;
225
+ current = parent;
226
+ }
227
+ return b;
228
+ }
229
+
230
+ /**
231
+ * Pre-compute group bounds from children.
232
+ * For groups with expandParent or small initial size, compute the actual
233
+ * bounding box from children positions + sizes and update the group's
234
+ * style.width/height so the router sees correct dimensions.
235
+ */
236
+ static computeGroupBounds(nodes: FlowNode[], padding = 20): FlowNode[] {
237
+ const childrenByParent = new Map<string, FlowNode[]>();
238
+ for (const node of nodes) {
239
+ if (node.parentId) {
240
+ if (!childrenByParent.has(node.parentId)) childrenByParent.set(node.parentId, []);
241
+ childrenByParent.get(node.parentId)!.push(node);
242
+ }
243
+ }
244
+
245
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
246
+ const computedSizes = new Map<string, { width: number; height: number }>();
247
+
248
+ // Bottom-up: compute sizes for deepest groups first
249
+ function computeSize(groupId: string): { width: number; height: number } {
250
+ const cached = computedSizes.get(groupId);
251
+ if (cached) return cached;
252
+
253
+ const children = childrenByParent.get(groupId) ?? [];
254
+ if (children.length === 0) {
255
+ const node = nodeById.get(groupId);
256
+ return node ? { width: Geometry.getNodeBounds(node).w, height: Geometry.getNodeBounds(node).h } : { width: 150, height: 50 };
257
+ }
258
+
259
+ let maxRight = 0;
260
+ let maxBottom = 0;
261
+
262
+ for (const child of children) {
263
+ // Recursively compute if child is also a group
264
+ if (child.type === "group" && childrenByParent.has(child.id)) {
265
+ const childSize = computeSize(child.id);
266
+ computedSizes.set(child.id, childSize);
267
+ }
268
+ const childBounds = Geometry.getNodeBounds(child);
269
+ const childSize = computedSizes.get(child.id);
270
+ const w = childSize?.width ?? childBounds.w;
271
+ const h = childSize?.height ?? childBounds.h;
272
+ maxRight = Math.max(maxRight, childBounds.x + w);
273
+ maxBottom = Math.max(maxBottom, childBounds.y + h);
274
+ }
275
+
276
+ const size = {
277
+ width: maxRight + padding,
278
+ height: maxBottom + padding,
279
+ };
280
+ computedSizes.set(groupId, size);
281
+ return size;
282
+ }
283
+
284
+ // Compute all groups
285
+ for (const node of nodes) {
286
+ if (node.type === "group" && childrenByParent.has(node.id)) {
287
+ computeSize(node.id);
288
+ }
289
+ }
290
+
291
+ // Apply computed sizes to group nodes
292
+ if (computedSizes.size === 0) return nodes;
293
+
294
+ return nodes.map((node) => {
295
+ const size = computedSizes.get(node.id);
296
+ if (!size) return node;
297
+ const currentBounds = Geometry.getNodeBounds(node);
298
+ // Only override if computed is larger than current
299
+ if (size.width > currentBounds.w || size.height > currentBounds.h) {
300
+ return {
301
+ ...node,
302
+ style: {
303
+ ...((node.style ?? {}) as Record<string, unknown>),
304
+ width: Math.max(size.width, currentBounds.w),
305
+ height: Math.max(size.height, currentBounds.h),
306
+ },
307
+ };
308
+ }
309
+ return node;
310
+ });
311
+ }
312
+
313
+ static getHandlePosition(node: FlowNode, kind: "source" | "target"): HandlePosition {
314
+ const raw =
315
+ kind === "source"
316
+ ? (node.sourcePosition as string | undefined) ?? (node as { data?: { sourcePosition?: string } }).data?.sourcePosition
317
+ : (node.targetPosition as string | undefined) ?? (node as { data?: { targetPosition?: string } }).data?.targetPosition;
318
+ const s = String(raw ?? "").toLowerCase();
319
+ if (s === "left" || s === "right" || s === "top" || s === "bottom") return s;
320
+ return kind === "source" ? "right" : "left";
321
+ }
322
+
323
+ static getHandlePoint(
324
+ bounds: { x: number; y: number; w: number; h: number },
325
+ position: HandlePosition
326
+ ): { x: number; y: number } {
327
+ const { x, y, w, h } = bounds;
328
+ const cx = x + w / 2;
329
+ const cy = y + h / 2;
330
+ switch (position) {
331
+ case "left": return { x, y: cy };
332
+ case "right": return { x: x + w, y: cy };
333
+ case "top": return { x: cx, y };
334
+ case "bottom": return { x: cx, y: y + h };
335
+ default: return { x: x + w, y: cy };
336
+ }
337
+ }
338
+
339
+ static snapToGrid(x: number, y: number, gridSize: number): { x: number; y: number } {
340
+ if (gridSize <= 0) return { x, y };
341
+ return { x: Math.round(x / gridSize) * gridSize, y: Math.round(y / gridSize) * gridSize };
342
+ }
343
+
344
+ static getBestSides(
345
+ srcBounds: { x: number; y: number; w: number; h: number },
346
+ tgtBounds: { x: number; y: number; w: number; h: number }
347
+ ): { sourcePos: HandlePosition; targetPos: HandlePosition } {
348
+ const dx = (tgtBounds.x + tgtBounds.w / 2) - (srcBounds.x + srcBounds.w / 2);
349
+ const dy = (tgtBounds.y + tgtBounds.h / 2) - (srcBounds.y + srcBounds.h / 2);
350
+ if (Math.abs(dx) >= Math.abs(dy)) {
351
+ return dx >= 0 ? { sourcePos: "right", targetPos: "left" } : { sourcePos: "left", targetPos: "right" };
352
+ }
353
+ return dy >= 0 ? { sourcePos: "bottom", targetPos: "top" } : { sourcePos: "top", targetPos: "bottom" };
354
+ }
355
+
356
+ /** Get ConnDir constant for a side */
357
+ static sideToDir(side: HandlePosition): number {
358
+ switch (side) {
359
+ case "top": return ConnDirUp;
360
+ case "bottom": return ConnDirDown;
361
+ case "left": return ConnDirLeft;
362
+ case "right": return ConnDirRight;
363
+ }
364
+ }
365
+ }
366
+
367
+ // ---- SVG Path Builder ----
368
+
369
+ export class PathBuilder {
370
+ static polylineToPath(
371
+ size: number,
372
+ getPoint: (i: number) => { x: number; y: number },
373
+ options: { gridSize?: number; cornerRadius?: number } = {}
374
+ ): string {
375
+ if (size < 2) return "";
376
+ const gridSize = options.gridSize ?? 0;
377
+ const r = Math.max(0, options.cornerRadius ?? 0);
378
+ const pt = (i: number) => {
379
+ const p = getPoint(i);
380
+ return gridSize > 0 ? Geometry.snapToGrid(p.x, p.y, gridSize) : p;
381
+ };
382
+ if (r <= 0) {
383
+ let d = `M ${pt(0).x} ${pt(0).y}`;
384
+ for (let i = 1; i < size; i++) { const p = pt(i); d += ` L ${p.x} ${p.y}`; }
385
+ return d;
386
+ }
387
+ const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(b.x - a.x, b.y - a.y);
388
+ const unit = (a: { x: number; y: number }, b: { x: number; y: number }) => {
389
+ const d = dist(a, b); if (d < 1e-6) return { x: 0, y: 0 };
390
+ return { x: (b.x - a.x) / d, y: (b.y - a.y) / d };
391
+ };
392
+ let d = `M ${pt(0).x} ${pt(0).y}`;
393
+ for (let i = 1; i < size - 1; i++) {
394
+ const prev = pt(i - 1), curr = pt(i), next = pt(i + 1);
395
+ const dirIn = unit(curr, prev), dirOut = unit(curr, next);
396
+ const rr = Math.min(r, dist(curr, prev) / 2, dist(curr, next) / 2);
397
+ const endPrev = { x: curr.x + dirIn.x * rr, y: curr.y + dirIn.y * rr };
398
+ const startNext = { x: curr.x + dirOut.x * rr, y: curr.y + dirOut.y * rr };
399
+ d += ` L ${endPrev.x} ${endPrev.y} Q ${curr.x} ${curr.y} ${startNext.x} ${startNext.y}`;
400
+ }
401
+ d += ` L ${pt(size - 1).x} ${pt(size - 1).y}`;
402
+ return d;
403
+ }
404
+
405
+ /**
406
+ * Convert routed waypoints directly to an SVG path string.
407
+ * No modifications — just M + L. libavoid already computed the correct positions.
408
+ */
409
+ static pointsToSvgPath(points: { x: number; y: number }[]): string {
410
+ if (points.length === 0) return "";
411
+ let d = `M ${points[0].x} ${points[0].y}`;
412
+ for (let i = 1; i < points.length; i++) {
413
+ d += ` L ${points[i].x} ${points[i].y}`;
414
+ }
415
+ return d;
416
+ }
417
+
418
+ /**
419
+ * Convert routed waypoints to a smooth bezier spline.
420
+ *
421
+ * Takes the orthogonal waypoints from libavoid, keeps only corners
422
+ * (where direction changes), then draws a smooth cubic bezier spline
423
+ * through them. The result is a flowing curve — not orthogonal at all —
424
+ * that still follows the obstacle-avoiding route.
425
+ */
426
+ static routedBezierPath(
427
+ points: { x: number; y: number }[],
428
+ options: { gridSize?: number } = {}
429
+ ): string {
430
+ if (points.length < 2) return "";
431
+ const gridSize = options.gridSize ?? 0;
432
+ const snap = (p: { x: number; y: number }) =>
433
+ gridSize > 0 ? Geometry.snapToGrid(p.x, p.y, gridSize) : p;
434
+
435
+ const raw = points.map(snap);
436
+
437
+ // Deduplicate
438
+ const deduped: { x: number; y: number }[] = [raw[0]];
439
+ for (let i = 1; i < raw.length; i++) {
440
+ const prev = deduped[deduped.length - 1];
441
+ if (Math.abs(raw[i].x - prev.x) > 0.5 || Math.abs(raw[i].y - prev.y) > 0.5) {
442
+ deduped.push(raw[i]);
443
+ }
444
+ }
445
+
446
+ // Remove collinear midpoints — keep only corners + endpoints
447
+ const pts: { x: number; y: number }[] = [deduped[0]];
448
+ for (let i = 1; i < deduped.length - 1; i++) {
449
+ const prev = deduped[i - 1];
450
+ const curr = deduped[i];
451
+ const next = deduped[i + 1];
452
+ const sameX = Math.abs(prev.x - curr.x) < 1 && Math.abs(curr.x - next.x) < 1;
453
+ const sameY = Math.abs(prev.y - curr.y) < 1 && Math.abs(curr.y - next.y) < 1;
454
+ if (!sameX || !sameY) pts.push(curr);
455
+ }
456
+ pts.push(deduped[deduped.length - 1]);
457
+
458
+ if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`;
459
+
460
+ // 2 points: simple bezier with offset control points
461
+ if (pts.length === 2) {
462
+ const [s, t] = pts;
463
+ const dx = t.x - s.x;
464
+ const dy = t.y - s.y;
465
+ const offset = Math.max(50, Math.max(Math.abs(dx), Math.abs(dy)) * 0.5);
466
+ if (Math.abs(dx) >= Math.abs(dy)) {
467
+ const sign = dx >= 0 ? 1 : -1;
468
+ return `M ${s.x} ${s.y} C ${s.x + offset * sign} ${s.y}, ${t.x - offset * sign} ${t.y}, ${t.x} ${t.y}`;
469
+ }
470
+ const sign = dy >= 0 ? 1 : -1;
471
+ return `M ${s.x} ${s.y} C ${s.x} ${s.y + offset * sign}, ${t.x} ${t.y - offset * sign}, ${t.x} ${t.y}`;
472
+ }
473
+
474
+ // 3+ points: smooth cubic bezier spline through all corner points.
475
+ // For each segment i→i+1, compute tangent-based control points using
476
+ // neighbors (Catmull-Rom style, tension 0.3).
477
+ const tension = 0.3;
478
+ const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
479
+ Math.hypot(b.x - a.x, b.y - a.y);
480
+
481
+ let d = `M ${pts[0].x} ${pts[0].y}`;
482
+
483
+ for (let i = 0; i < pts.length - 1; i++) {
484
+ const p0 = pts[Math.max(0, i - 1)];
485
+ const p1 = pts[i];
486
+ const p2 = pts[i + 1];
487
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
488
+
489
+ const segLen = dist(p1, p2);
490
+
491
+ // Tangent at p1: direction from p0 to p2
492
+ let cp1x = p1.x + (p2.x - p0.x) * tension;
493
+ let cp1y = p1.y + (p2.y - p0.y) * tension;
494
+ // Tangent at p2: direction from p1 to p3
495
+ let cp2x = p2.x - (p3.x - p1.x) * tension;
496
+ let cp2y = p2.y - (p3.y - p1.y) * tension;
497
+
498
+ // Clamp control points — don't extend past 40% of segment length
499
+ const maxReach = segLen * 0.4;
500
+ const cp1d = dist(p1, { x: cp1x, y: cp1y });
501
+ if (cp1d > maxReach && cp1d > 0) {
502
+ const s = maxReach / cp1d;
503
+ cp1x = p1.x + (cp1x - p1.x) * s;
504
+ cp1y = p1.y + (cp1y - p1.y) * s;
505
+ }
506
+ const cp2d = dist(p2, { x: cp2x, y: cp2y });
507
+ if (cp2d > maxReach && cp2d > 0) {
508
+ const s = maxReach / cp2d;
509
+ cp2x = p2.x + (cp2x - p2.x) * s;
510
+ cp2y = p2.y + (cp2y - p2.y) * s;
511
+ }
512
+
513
+ d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
514
+ }
515
+
516
+ return d;
517
+ }
518
+ }
519
+
520
+ // ---- Handle Spacing ----
521
+
522
+ export class HandleSpacing {
523
+ static adjust(
524
+ edges: FlowEdge[],
525
+ edgePoints: Map<string, { x: number; y: number }[]>,
526
+ handleNudging: number,
527
+ idealNudging: number
528
+ ): void {
529
+ const ratio = handleNudging / idealNudging;
530
+ const bySource = new Map<string, string[]>();
531
+ const byTarget = new Map<string, string[]>();
532
+ for (const edge of edges) {
533
+ if (!edgePoints.has(edge.id)) continue;
534
+ if (!bySource.has(edge.source)) bySource.set(edge.source, []);
535
+ bySource.get(edge.source)!.push(edge.id);
536
+ if (!byTarget.has(edge.target)) byTarget.set(edge.target, []);
537
+ byTarget.get(edge.target)!.push(edge.id);
538
+ }
539
+ for (const [, ids] of bySource) { if (ids.length >= 2) HandleSpacing.rescale(ids, edgePoints, "source", ratio); }
540
+ for (const [, ids] of byTarget) { if (ids.length >= 2) HandleSpacing.rescale(ids, edgePoints, "target", ratio); }
541
+ }
542
+
543
+ private static rescale(edgeIds: string[], edgePoints: Map<string, { x: number; y: number }[]>, end: "source" | "target", ratio: number) {
544
+ const positions: { edgeId: string; pt: { x: number; y: number } }[] = [];
545
+ for (const edgeId of edgeIds) {
546
+ const pts = edgePoints.get(edgeId);
547
+ if (!pts || pts.length < 2) continue;
548
+ positions.push({ edgeId, pt: pts[end === "source" ? 0 : pts.length - 1] });
549
+ }
550
+ if (positions.length < 2) return;
551
+ const firstPts = edgePoints.get(positions[0].edgeId)!;
552
+ const segStart = end === "source" ? 0 : firstPts.length - 2;
553
+ const isHorizontal = Math.abs(firstPts[segStart + 1].x - firstPts[segStart].x) > Math.abs(firstPts[segStart + 1].y - firstPts[segStart].y);
554
+ const axis = isHorizontal ? "y" : "x";
555
+ const values = positions.map((p) => p.pt[axis]);
556
+ const center = values.reduce((a, b) => a + b, 0) / values.length;
557
+ const spread = Math.max(...values) - Math.min(...values);
558
+
559
+ if (spread > 0.5) {
560
+ for (const edgeId of edgeIds) {
561
+ const pts = edgePoints.get(edgeId);
562
+ if (!pts || pts.length < 2) continue;
563
+ for (const idx of (end === "source" ? [0, 1] : [pts.length - 1, pts.length - 2])) {
564
+ pts[idx][axis] = center + (pts[idx][axis] - center) * ratio;
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+
571
+ // ---- Pin Registry (assigns stable numeric IDs to handle pins) ----
572
+
573
+ class PinRegistry {
574
+ private nextId = 1;
575
+ /** nodeId:handleId → pinClassId */
576
+ private map = new Map<string, number>();
577
+
578
+ getOrCreate(nodeId: string, handleId: string): number {
579
+ const key = `${nodeId}:${handleId}`;
580
+ let id = this.map.get(key);
581
+ if (id == null) { id = this.nextId++; this.map.set(key, id); }
582
+ return id;
583
+ }
584
+
585
+ clear() { this.map.clear(); this.nextId = 1; }
586
+ }
587
+
588
+ // ---- Router configuration ----
589
+
590
+ function configureRouter(router: AvoidRouter, options: AvoidRouterOptions): void {
591
+ // --- Routing parameters ---
592
+ router.setRoutingParameter(shapeBufferDistanceParam, options.shapeBufferDistance ?? 8);
593
+ router.setRoutingParameter(idealNudgingDistanceParam, options.idealNudgingDistance ?? 10);
594
+ // segmentPenalty MUST be >0 for orthogonal nudging to work (libavoid C++ docs)
595
+ router.setRoutingParameter(segmentPenaltyParam, options.segmentPenalty ?? 10);
596
+ if (options.anglePenalty != null && options.anglePenalty > 0) {
597
+ router.setRoutingParameter(anglePenaltyParam, options.anglePenalty);
598
+ }
599
+ if (options.crossingPenalty != null && options.crossingPenalty > 0) {
600
+ router.setRoutingParameter(crossingPenaltyParam, options.crossingPenalty);
601
+ }
602
+ if (options.clusterCrossingPenalty != null && options.clusterCrossingPenalty > 0) {
603
+ router.setRoutingParameter(clusterCrossingPenaltyParam, options.clusterCrossingPenalty);
604
+ }
605
+ if (options.fixedSharedPathPenalty != null && options.fixedSharedPathPenalty > 0) {
606
+ router.setRoutingParameter(fixedSharedPathPenaltyParam, options.fixedSharedPathPenalty);
607
+ }
608
+ if (options.portDirectionPenalty != null && options.portDirectionPenalty > 0) {
609
+ router.setRoutingParameter(portDirectionPenaltyParam, options.portDirectionPenalty);
610
+ }
611
+ if (options.reverseDirectionPenalty != null && options.reverseDirectionPenalty > 0) {
612
+ router.setRoutingParameter(reverseDirectionPenaltyParam, options.reverseDirectionPenalty);
613
+ }
614
+
615
+ // --- Routing options ---
616
+ const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
617
+ // When splitNearHandle is off, disable nudging at shapes so edges converge to exact handle point
618
+ router.setRoutingOption(nudgeOrthogonalSegmentsConnectedToShapesOpt, splitNearHandle ? (options.nudgeOrthogonalSegmentsConnectedToShapes ?? true) : false);
619
+ router.setRoutingOption(nudgeSharedPathsWithCommonEndPointOpt, splitNearHandle ? (options.nudgeSharedPathsWithCommonEndPoint ?? true) : false);
620
+ router.setRoutingOption(performUnifyingNudgingPreprocessingStepOpt, options.performUnifyingNudgingPreprocessingStep ?? true);
621
+ router.setRoutingOption(nudgeOrthogonalTouchingColinearSegmentsOpt, options.nudgeOrthogonalTouchingColinearSegments ?? false);
622
+ router.setRoutingOption(improveHyperedgeRoutesMovingJunctionsOpt, options.improveHyperedgeRoutesMovingJunctions ?? true);
623
+ router.setRoutingOption(penaliseOrthogonalSharedPathsAtConnEndsOpt, options.penaliseOrthogonalSharedPathsAtConnEnds ?? false);
624
+ router.setRoutingOption(improveHyperedgeRoutesMovingAddingAndDeletingJunctionsOpt, options.improveHyperedgeRoutesMovingAddingAndDeletingJunctions ?? false);
625
+ }
626
+
627
+ // ---- Routing helpers ----
628
+
629
+ function createObstacles(
630
+ router: AvoidRouter,
631
+ nodes: FlowNode[],
632
+ nodeById: Map<string, FlowNode>,
633
+ pinRegistry: PinRegistry,
634
+ options: AvoidRouterOptions
635
+ ): { shapeRefMap: Map<string, AvoidShapeRef>; shapeRefList: AvoidShapeRef[] } {
636
+ const shapeRefMap = new Map<string, AvoidShapeRef>();
637
+ const shapeRefList: AvoidShapeRef[] = [];
638
+ const obstacleNodes = nodes.filter((n) => n.type !== "group");
639
+ const insideOffset = options.pinInsideOffset ?? 0;
640
+
641
+ for (const node of obstacleNodes) {
642
+ const b = Geometry.getNodeBoundsAbsolute(node, nodeById);
643
+ const topLeft = new AvoidPoint(b.x, b.y);
644
+ const bottomRight = new AvoidPoint(b.x + b.w, b.y + b.h);
645
+ const rect = new AvoidRectangle(topLeft, bottomRight);
646
+ const shapeRef = new AvoidShapeRef(router as any, rect);
647
+ shapeRefList.push(shapeRef);
648
+ shapeRefMap.set(node.id, shapeRef);
649
+
650
+ // Create pins at exact SVG anchor positions for each handle
651
+ const pins = (node._handlePins as HandlePin[] | undefined) ?? [];
652
+ for (const pin of pins) {
653
+ const pinId = pinRegistry.getOrCreate(node.id, pin.handleId);
654
+ const dir = Geometry.sideToDir(pin.side);
655
+ const sp = AvoidShapeConnectionPin.createForShape(
656
+ shapeRef as any, pinId, pin.xPct, pin.yPct, true, insideOffset, dir
657
+ );
658
+ sp.setExclusive(false);
659
+ if (pin.cost != null && pin.cost > 0) {
660
+ sp.setConnectionCost(pin.cost);
661
+ }
662
+ }
663
+
664
+ // Pre-create auto pins on all 4 sides sharing the SAME pinClassId.
665
+ // This lets libavoid choose the best side during routing instead of
666
+ // us pre-selecting one side with a naive heuristic.
667
+ const autoId = pinRegistry.getOrCreate(node.id, `__auto_best`);
668
+ const sides: HandlePosition[] = ["top", "bottom", "left", "right"];
669
+ for (const side of sides) {
670
+ let xPct: number, yPct: number;
671
+ switch (side) {
672
+ case "left": xPct = 0; yPct = 0.5; break;
673
+ case "right": xPct = 1; yPct = 0.5; break;
674
+ case "top": xPct = 0.5; yPct = 0; break;
675
+ case "bottom": xPct = 0.5; yPct = 1; break;
676
+ }
677
+ const dir = Geometry.sideToDir(side);
678
+ const autoSp = AvoidShapeConnectionPin.createForShape(
679
+ shapeRef as any, autoId, xPct, yPct, true, insideOffset, dir
680
+ );
681
+ autoSp.setExclusive(false);
682
+ }
683
+ }
684
+
685
+ return { shapeRefMap, shapeRefList };
686
+ }
687
+
688
+
689
+ /**
690
+ * Find the first enriched pin matching a handle type (source/target).
691
+ * Used when edge.sourceHandle/targetHandle is null (default handles).
692
+ */
693
+ function findDefaultHandle(node: FlowNode, kind: "source" | "target"): string | null {
694
+ const pins = (node._handlePins as HandlePin[] | undefined) ?? [];
695
+ // Enriched pins from default handles are named __source_N or __target_N
696
+ const prefix = `__${kind}_`;
697
+ const pin = pins.find((p) => p.handleId.startsWith(prefix));
698
+ return pin?.handleId ?? null;
699
+ }
700
+
701
+
702
+ /** Offset a point away from the node border by stubLength in the direction of the side */
703
+ function offsetFromSide(pt: { x: number; y: number }, side: HandlePosition, stubLength: number): { x: number; y: number } {
704
+ switch (side) {
705
+ case "left": return { x: pt.x - stubLength, y: pt.y };
706
+ case "right": return { x: pt.x + stubLength, y: pt.y };
707
+ case "top": return { x: pt.x, y: pt.y - stubLength };
708
+ case "bottom": return { x: pt.x, y: pt.y + stubLength };
709
+ }
710
+ }
711
+
712
+ /** Info needed to add stubs after routing when splitNearHandle is off */
713
+ type StubInfo = {
714
+ edgeId: string;
715
+ srcHandlePt: { x: number; y: number };
716
+ tgtHandlePt: { x: number; y: number };
717
+ };
718
+
719
+ function createConnections(
720
+ router: AvoidRouter,
721
+ edges: FlowEdge[],
722
+ nodeById: Map<string, FlowNode>,
723
+ shapeRefMap: Map<string, AvoidShapeRef>,
724
+ pinRegistry: PinRegistry,
725
+ options: AvoidRouterOptions
726
+ ): { connRefs: { edgeId: string; connRef: AvoidConnRef }[]; stubs: StubInfo[] } {
727
+ const connRefs: { edgeId: string; connRef: AvoidConnRef }[] = [];
728
+ const stubs: StubInfo[] = [];
729
+ const autoBestSide = options.autoBestSideConnection ?? true;
730
+ const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
731
+ const stubLength = options.stubSize ?? 20;
732
+ const connType = getConnType(options.connectorType);
733
+ const hateCrossings = options.hateCrossings ?? false;
734
+
735
+ for (const edge of edges) {
736
+ const src = nodeById.get(edge.source);
737
+ const tgt = nodeById.get(edge.target);
738
+ if (!src || !tgt) continue;
739
+
740
+ const srcShapeRef = shapeRefMap.get(edge.source);
741
+ const tgtShapeRef = shapeRefMap.get(edge.target);
742
+
743
+ const srcBounds = Geometry.getNodeBoundsAbsolute(src, nodeById);
744
+ const tgtBounds = Geometry.getNodeBoundsAbsolute(tgt, nodeById);
745
+
746
+ let srcEnd: AvoidConnEnd;
747
+ let tgtEnd: AvoidConnEnd;
748
+
749
+ if (splitNearHandle) {
750
+ // Pin-based: edges spread out along the node border near handles
751
+ const srcHandle = edge.sourceHandle ?? (autoBestSide ? null : findDefaultHandle(src, "source"));
752
+ if (srcShapeRef && srcHandle) {
753
+ const pinId = pinRegistry.getOrCreate(edge.source, srcHandle);
754
+ srcEnd = AvoidConnEnd.fromShapePin(srcShapeRef as any, pinId);
755
+ } else if (srcShapeRef) {
756
+ srcEnd = AvoidConnEnd.fromShapePin(srcShapeRef as any, pinRegistry.getOrCreate(edge.source, `__auto_best`));
757
+ } else {
758
+ const side = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
759
+ srcEnd = (() => { const pt = Geometry.getHandlePoint(srcBounds, side); return AvoidConnEnd.fromPoint(new AvoidPoint(pt.x, pt.y)); })();
760
+ }
761
+
762
+ const tgtHandle = edge.targetHandle ?? (autoBestSide ? null : findDefaultHandle(tgt, "target"));
763
+ if (tgtShapeRef && tgtHandle) {
764
+ const pinId = pinRegistry.getOrCreate(edge.target, tgtHandle);
765
+ tgtEnd = AvoidConnEnd.fromShapePin(tgtShapeRef as any, pinId);
766
+ } else if (tgtShapeRef) {
767
+ tgtEnd = AvoidConnEnd.fromShapePin(tgtShapeRef as any, pinRegistry.getOrCreate(edge.target, `__auto_best`));
768
+ } else {
769
+ const side = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
770
+ tgtEnd = (() => { const pt = Geometry.getHandlePoint(tgtBounds, side); return AvoidConnEnd.fromPoint(new AvoidPoint(pt.x, pt.y)); })();
771
+ }
772
+ } else {
773
+ // Point-based with stubs: all edges from the same side converge to center of side,
774
+ // then route from a stub point offset outward
775
+ const srcSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
776
+ const tgtSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
777
+
778
+ // Use center of the side (ignore individual handle positions)
779
+ const srcHandlePt = Geometry.getHandlePoint(srcBounds, srcSide);
780
+ const tgtHandlePt = Geometry.getHandlePoint(tgtBounds, tgtSide);
781
+
782
+ // Route from stub endpoints (offset from center of side)
783
+ const srcStubPt = offsetFromSide(srcHandlePt, srcSide, stubLength);
784
+ const tgtStubPt = offsetFromSide(tgtHandlePt, tgtSide, stubLength);
785
+
786
+ srcEnd = AvoidConnEnd.fromPoint(new AvoidPoint(srcStubPt.x, srcStubPt.y));
787
+ tgtEnd = AvoidConnEnd.fromPoint(new AvoidPoint(tgtStubPt.x, tgtStubPt.y));
788
+
789
+ stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt });
790
+ }
791
+
792
+ const connRef = new AvoidConnRef(router as any, srcEnd, tgtEnd);
793
+ connRef.setRoutingType(connType);
794
+
795
+ if (hateCrossings) {
796
+ connRef.setHateCrossings(true);
797
+ }
798
+
799
+ // Set checkpoints if provided
800
+ if (edge.checkpoints && edge.checkpoints.length > 0) {
801
+ const checkpoints = edge.checkpoints.map(
802
+ (cp) => new AvoidCheckpoint(new AvoidPoint(cp.x, cp.y))
803
+ );
804
+ connRef.setRoutingCheckpoints(checkpoints);
805
+ }
806
+
807
+ connRefs.push({ edgeId: edge.id, connRef });
808
+ }
809
+
810
+ return { connRefs, stubs };
811
+ }
812
+
813
+ // ---- Routing Engine ----
814
+
815
+ export class RoutingEngine {
816
+ static routeAll(
817
+ rawNodes: FlowNode[],
818
+ edges: FlowEdge[],
819
+ options?: AvoidRouterOptions
820
+ ): Record<string, AvoidRoute> {
821
+ const opts = options ?? {};
822
+ const gridSize = opts.diagramGridSize ?? 0;
823
+ // Pre-compute group bounds so groups with expandParent have correct sizes
824
+ const nodes = Geometry.computeGroupBounds(rawNodes);
825
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
826
+ const pinRegistry = new PinRegistry();
827
+
828
+ const routerFlags = getRouterFlags(opts.connectorType);
829
+ const router = createRouter(routerFlags);
830
+ configureRouter(router, opts);
831
+
832
+ const { shapeRefMap, shapeRefList } = createObstacles(router, nodes, nodeById, pinRegistry, opts);
833
+ const { connRefs, stubs } = createConnections(router, edges, nodeById, shapeRefMap, pinRegistry, opts);
834
+
835
+ const result: Record<string, AvoidRoute> = {};
836
+ try { router.processTransaction(); } catch (e) { console.error("[edge-routing] processTransaction failed:", e); RoutingEngine.cleanup(router, connRefs, shapeRefList); return result; }
837
+
838
+ // Read routed points directly from libavoid
839
+ const edgePoints = RoutingEngine.extractRoutePoints(connRefs);
840
+
841
+ // Prepend/append stub handle points for non-split mode
842
+ const stubMap = new Map(stubs.map((s) => [s.edgeId, s]));
843
+ for (const [edgeId, points] of edgePoints) {
844
+ const stub = stubMap.get(edgeId);
845
+ if (stub) {
846
+ points.unshift(stub.srcHandlePt);
847
+ points.push(stub.tgtHandlePt);
848
+ }
849
+ }
850
+
851
+ // Adjust spacing at shared handles (fan-out effect) — skip when splitNearHandle is off
852
+ const splitNearHandle = opts.shouldSplitEdgesNearHandle ?? true;
853
+ if (splitNearHandle) {
854
+ const idealNudging = opts.idealNudgingDistance ?? 10;
855
+ const handleNudging = opts.handleNudgingDistance ?? idealNudging;
856
+ if (handleNudging !== idealNudging && edgePoints.size > 0) {
857
+ HandleSpacing.adjust(edges, edgePoints, handleNudging, idealNudging);
858
+ }
859
+ }
860
+
861
+ const connType = opts.connectorType ?? "orthogonal";
862
+ for (const [edgeId, points] of edgePoints) {
863
+ const edgeRounding = opts.edgeRounding ?? 0;
864
+ const path = connType === "bezier"
865
+ ? PathBuilder.routedBezierPath(points)
866
+ : edgeRounding > 0
867
+ ? PathBuilder.polylineToPath(points.length, (i) => points[i], { cornerRadius: edgeRounding })
868
+ : PathBuilder.pointsToSvgPath(points);
869
+ const mid = Math.floor(points.length / 2);
870
+ const midP = points[mid];
871
+ const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
872
+ const first = points[0];
873
+ const last = points[points.length - 1];
874
+ result[edgeId] = { path, labelX: labelP.x, labelY: labelP.y, sourceX: first.x, sourceY: first.y, targetX: last.x, targetY: last.y };
875
+ }
876
+
877
+ RoutingEngine.cleanup(router, connRefs, shapeRefList);
878
+ return result;
879
+ }
880
+
881
+ static extractRoutePoints(connRefs: { edgeId: string; connRef: AvoidConnRef }[]): Map<string, { x: number; y: number }[]> {
882
+ const edgePoints = new Map<string, { x: number; y: number }[]>();
883
+ for (const { edgeId, connRef } of connRefs) {
884
+ try {
885
+ const route = connRef.displayRoute();
886
+ const size = route.size();
887
+ if (size < 2) continue;
888
+ const points: { x: number; y: number }[] = [];
889
+ for (let i = 0; i < size; i++) { const p = route.at(i); points.push({ x: p.x, y: p.y }); }
890
+ edgePoints.set(edgeId, points);
891
+ } catch { /* skip */ }
892
+ }
893
+ return edgePoints;
894
+ }
895
+
896
+ static cleanup(
897
+ router: AvoidRouter,
898
+ connRefs: { connRef: AvoidConnRef }[],
899
+ shapeRefs: AvoidShapeRef[]
900
+ ) {
901
+ try {
902
+ for (const { connRef } of connRefs) router.deleteConnector(connRef as any);
903
+ for (const ref of shapeRefs) router.deleteShape(ref);
904
+ } catch { /* ignore */ }
905
+ }
906
+ }
907
+
908
+ // ---- Persistent Router ----
909
+
910
+ export class PersistentRouter {
911
+ private router: AvoidRouter | null = null;
912
+ private shapeRefMap = new Map<string, AvoidShapeRef>();
913
+ private shapeRefList: AvoidShapeRef[] = [];
914
+ private connRefList: { edgeId: string; connRef: AvoidConnRef }[] = [];
915
+ private stubList: StubInfo[] = [];
916
+ private prevNodes: FlowNode[] = [];
917
+ private prevEdges: FlowEdge[] = [];
918
+ private prevOptions: AvoidRouterOptions = {};
919
+ private nodeById = new Map<string, FlowNode>();
920
+ private pinRegistry = new PinRegistry();
921
+
922
+ reset(nodes: FlowNode[], edges: FlowEdge[], options?: AvoidRouterOptions): Record<string, AvoidRoute> {
923
+ this.prevNodes = nodes;
924
+ this.prevEdges = edges;
925
+ if (options) this.prevOptions = options;
926
+ this.nodeById = new Map(nodes.map((n) => [n.id, n]));
927
+ return this.buildRouter();
928
+ }
929
+
930
+ updateNodes(updatedNodes: FlowNode[]): Record<string, AvoidRoute> {
931
+ if (!this.router) {
932
+ for (const updated of updatedNodes) this.upsertNode(updated);
933
+ this.nodeById = new Map(this.prevNodes.map((n) => [n.id, n]));
934
+ return this.buildRouter();
935
+ }
936
+ for (const updated of updatedNodes) {
937
+ this.upsertNode(updated);
938
+ const shapeRef = this.shapeRefMap.get(updated.id);
939
+ if (shapeRef && updated.position) {
940
+ const b = Geometry.getNodeBoundsAbsolute(this.nodeById.get(updated.id)!, this.nodeById);
941
+ const topLeft = new AvoidPoint(b.x, b.y);
942
+ const bottomRight = new AvoidPoint(b.x + b.w, b.y + b.h);
943
+ this.router.moveShape(shapeRef, new AvoidRectangle(topLeft, bottomRight));
944
+ }
945
+ }
946
+ try { this.router.processTransaction(); }
947
+ catch { return this.reset(this.prevNodes, this.prevEdges, this.prevOptions); }
948
+ return this.readRoutes();
949
+ }
950
+
951
+ destroy(): void {
952
+ if (this.router) {
953
+ RoutingEngine.cleanup(this.router, this.connRefList, this.shapeRefList);
954
+ this.router = null;
955
+ }
956
+ this.shapeRefMap.clear();
957
+ this.shapeRefList = [];
958
+ this.connRefList = [];
959
+ this.pinRegistry.clear();
960
+ }
961
+
962
+ private upsertNode(updated: FlowNode) {
963
+ const existing = this.nodeById.get(updated.id);
964
+ if (existing) {
965
+ const merged = { ...existing, ...updated };
966
+ const i = this.prevNodes.indexOf(existing);
967
+ if (i >= 0) this.prevNodes[i] = merged;
968
+ this.nodeById.set(updated.id, merged);
969
+ } else {
970
+ this.prevNodes.push(updated);
971
+ this.nodeById.set(updated.id, updated);
972
+ }
973
+ }
974
+
975
+ private buildRouter(): Record<string, AvoidRoute> {
976
+ const opts = this.prevOptions;
977
+
978
+ // Pre-compute group bounds so groups with expandParent have correct sizes
979
+ this.prevNodes = Geometry.computeGroupBounds(this.prevNodes);
980
+ this.nodeById = new Map(this.prevNodes.map((n) => [n.id, n]));
981
+
982
+ // Clean up previous
983
+ if (this.router) {
984
+ try { RoutingEngine.cleanup(this.router, this.connRefList, this.shapeRefList); } catch { /* ok */ }
985
+ }
986
+
987
+ this.pinRegistry.clear();
988
+ const routerFlags = getRouterFlags(opts.connectorType);
989
+ this.router = createRouter(routerFlags);
990
+ configureRouter(this.router, opts);
991
+
992
+ const result = createObstacles(this.router, this.prevNodes, this.nodeById, this.pinRegistry, opts);
993
+ this.shapeRefMap = result.shapeRefMap;
994
+ this.shapeRefList = result.shapeRefList;
995
+ const conn = createConnections(this.router, this.prevEdges, this.nodeById, this.shapeRefMap, this.pinRegistry, opts);
996
+ this.connRefList = conn.connRefs;
997
+ this.stubList = conn.stubs;
998
+
999
+ try { this.router.processTransaction(); }
1000
+ catch { this.destroy(); return {}; }
1001
+
1002
+ return this.readRoutes();
1003
+ }
1004
+
1005
+ private readRoutes(): Record<string, AvoidRoute> {
1006
+ const opts = this.prevOptions;
1007
+ const idealNudging = opts.idealNudgingDistance ?? 10;
1008
+ const handleNudging = opts.handleNudgingDistance ?? idealNudging;
1009
+ const gridSize = opts.diagramGridSize ?? 0;
1010
+ const result: Record<string, AvoidRoute> = {};
1011
+
1012
+ // Read routed points directly from libavoid
1013
+ const edgePoints = RoutingEngine.extractRoutePoints(this.connRefList);
1014
+
1015
+ // Prepend/append stub handle points for non-split mode
1016
+ const stubMap = new Map(this.stubList.map((s) => [s.edgeId, s]));
1017
+ for (const [edgeId, points] of edgePoints) {
1018
+ const stub = stubMap.get(edgeId);
1019
+ if (stub) {
1020
+ points.unshift(stub.srcHandlePt);
1021
+ points.push(stub.tgtHandlePt);
1022
+ }
1023
+ }
1024
+
1025
+ // Adjust spacing at shared handles (fan-out effect) — skip when splitNearHandle is off
1026
+ const splitNearHandle = opts.shouldSplitEdgesNearHandle ?? true;
1027
+ if (splitNearHandle && handleNudging !== idealNudging && edgePoints.size > 0) {
1028
+ HandleSpacing.adjust(this.prevEdges, edgePoints, handleNudging, idealNudging);
1029
+ }
1030
+
1031
+ const connType = opts.connectorType ?? "orthogonal";
1032
+ for (const [edgeId, points] of edgePoints) {
1033
+ const edgeRounding = opts.edgeRounding ?? 0;
1034
+ const path = connType === "bezier"
1035
+ ? PathBuilder.routedBezierPath(points)
1036
+ : edgeRounding > 0
1037
+ ? PathBuilder.polylineToPath(points.length, (i) => points[i], { cornerRadius: edgeRounding })
1038
+ : PathBuilder.pointsToSvgPath(points);
1039
+
1040
+
1041
+ const mid = Math.floor(points.length / 2);
1042
+ const midP = points[mid];
1043
+ const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
1044
+ const first = points[0];
1045
+ const last = points[points.length - 1];
1046
+ result[edgeId] = { path, labelX: labelP.x, labelY: labelP.y, sourceX: first.x, sourceY: first.y, targetX: last.x, targetY: last.y };
1047
+ }
1048
+ return result;
1049
+ }
1050
+ }