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.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/package.json +24 -0
- package/src/constants.ts +6 -0
- package/src/edge-routing-store.ts +38 -0
- package/src/edge-routing.worker.ts +206 -0
- package/src/index.ts +44 -0
- package/src/resolve-collisions.ts +215 -0
- package/src/routing-core.ts +1050 -0
- package/src/use-edge-routing.ts +303 -0
- package/src/use-routed-edge-path.ts +99 -0
- package/src/use-routing-worker.ts +110 -0
- package/src/worker-listener.ts +48 -0
- package/src/worker-messages.ts +21 -0
- package/src/worker-polyfill.ts +8 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEdgeRouting
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* Routes edges around nodes using a Web Worker running libavoid WASM.
|
|
5
|
+
*
|
|
6
|
+
* Pins stay fixed at their exact SVG anchor positions. Routes go around nodes.
|
|
7
|
+
* Each node is enriched with _handlePins before sending to the worker.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
11
|
+
import type { Node, NodeChange, Edge } from "@xyflow/react";
|
|
12
|
+
import { useEdgeRoutingStore, useEdgeRoutingActionsStore } from "./edge-routing-store";
|
|
13
|
+
import { DEBOUNCE_ROUTING_MS } from "./constants";
|
|
14
|
+
import type { AvoidRouterOptions, FlowNode, FlowEdge, ConnectorType } from "./routing-core";
|
|
15
|
+
import { useRoutingWorker } from "./use-routing-worker";
|
|
16
|
+
|
|
17
|
+
export interface UseEdgeRoutingOptions {
|
|
18
|
+
// --- Core spacing ---
|
|
19
|
+
edgeToEdgeSpacing?: number;
|
|
20
|
+
edgeToNodeSpacing?: number;
|
|
21
|
+
/** Spacing (px) between edges at shared handles. Should be >= edgeToEdgeSpacing for a fan-out effect. */
|
|
22
|
+
handleSpacing?: number;
|
|
23
|
+
|
|
24
|
+
// --- libavoid routing parameters ---
|
|
25
|
+
/** Penalty for each segment beyond the first. MUST be >0 for nudging. Default: 10 */
|
|
26
|
+
segmentPenalty?: number;
|
|
27
|
+
/** Penalty for tight bends (polyline routing). Default: 0 */
|
|
28
|
+
anglePenalty?: number;
|
|
29
|
+
/** Penalty for crossing other connectors. EXPERIMENTAL. Default: 0 */
|
|
30
|
+
crossingPenalty?: number;
|
|
31
|
+
/** Penalty for crossing cluster boundaries. EXPERIMENTAL. Default: 0 */
|
|
32
|
+
clusterCrossingPenalty?: number;
|
|
33
|
+
/** Penalty for shared paths with fixed connectors. EXPERIMENTAL. Default: 0 */
|
|
34
|
+
fixedSharedPathPenalty?: number;
|
|
35
|
+
/** Penalty for port selection outside visibility cone. EXPERIMENTAL. Default: 0 */
|
|
36
|
+
portDirectionPenalty?: number;
|
|
37
|
+
/** Penalty when connector travels opposite from destination. Default: 0 */
|
|
38
|
+
reverseDirectionPenalty?: number;
|
|
39
|
+
|
|
40
|
+
// --- libavoid routing options ---
|
|
41
|
+
/** Nudge final segments attached to shapes. Default: true */
|
|
42
|
+
nudgeOrthogonalSegmentsConnectedToShapes?: boolean;
|
|
43
|
+
/** Nudge intermediate segments at common endpoints. Default: true */
|
|
44
|
+
nudgeSharedPathsWithCommonEndPoint?: boolean;
|
|
45
|
+
/** Unify/center segments before nudging (better quality, slower). Default: true */
|
|
46
|
+
performUnifyingNudgingPreprocessingStep?: boolean;
|
|
47
|
+
/** Nudge colinear segments touching at ends apart. Default: false */
|
|
48
|
+
nudgeOrthogonalTouchingColinearSegments?: boolean;
|
|
49
|
+
/** Improve hyperedge routes by moving junctions. Default: true */
|
|
50
|
+
improveHyperedgeRoutesMovingJunctions?: boolean;
|
|
51
|
+
/** Penalize shared orthogonal paths at junctions/pins. EXPERIMENTAL. Default: false */
|
|
52
|
+
penaliseOrthogonalSharedPathsAtConnEnds?: boolean;
|
|
53
|
+
/** Improve hyperedges by adding/removing junctions. Default: false */
|
|
54
|
+
improveHyperedgeRoutesMovingAddingAndDeletingJunctions?: boolean;
|
|
55
|
+
|
|
56
|
+
// --- Connector settings ---
|
|
57
|
+
/** Edge path style: "orthogonal" (default), "polyline", or "bezier". */
|
|
58
|
+
connectorType?: ConnectorType;
|
|
59
|
+
/** If true, connectors try to avoid crossings (longer paths). Default: false */
|
|
60
|
+
hateCrossings?: boolean;
|
|
61
|
+
/** Inside offset (px) for pins — pushes connector start inside shape boundary. Default: 0 */
|
|
62
|
+
pinInsideOffset?: number;
|
|
63
|
+
|
|
64
|
+
// --- Rendering / layout ---
|
|
65
|
+
edgeRounding?: number;
|
|
66
|
+
diagramGridSize?: number;
|
|
67
|
+
/** When true, edges spread out along the node border near handles. When false, edges converge to exact handle point. Default: true */
|
|
68
|
+
shouldSplitEdgesNearHandle?: boolean;
|
|
69
|
+
/** Length (px) of the stub segment when shouldSplitEdgesNearHandle is off. Default: 20 */
|
|
70
|
+
stubSize?: number;
|
|
71
|
+
autoBestSideConnection?: boolean;
|
|
72
|
+
debounceMs?: number;
|
|
73
|
+
|
|
74
|
+
/** If true, re-route in real time while dragging instead of on drag stop. Default: false */
|
|
75
|
+
realTimeRouting?: boolean;
|
|
76
|
+
/** Enrich a node with _handlePins and _extraHeight before sending to worker */
|
|
77
|
+
enrichNode?: (node: Node) => Node;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UseEdgeRoutingResult {
|
|
81
|
+
updateRoutingOnNodesChange: (changes: NodeChange<Node>[]) => void;
|
|
82
|
+
resetRouting: () => void;
|
|
83
|
+
refreshRouting: () => void;
|
|
84
|
+
updateRoutingForNodeIds: (nodeIds: string[]) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const DEFAULT_OPTIONS: UseEdgeRoutingOptions = {
|
|
88
|
+
edgeRounding: 8,
|
|
89
|
+
edgeToEdgeSpacing: 10,
|
|
90
|
+
edgeToNodeSpacing: 8,
|
|
91
|
+
handleSpacing: 2,
|
|
92
|
+
diagramGridSize: 0,
|
|
93
|
+
shouldSplitEdgesNearHandle: true,
|
|
94
|
+
autoBestSideConnection: false,
|
|
95
|
+
debounceMs: 0,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function toRouterOptions(opts?: UseEdgeRoutingOptions): AvoidRouterOptions {
|
|
99
|
+
return {
|
|
100
|
+
// Core spacing
|
|
101
|
+
idealNudgingDistance: opts?.edgeToEdgeSpacing ?? DEFAULT_OPTIONS.edgeToEdgeSpacing,
|
|
102
|
+
shapeBufferDistance: opts?.edgeToNodeSpacing ?? DEFAULT_OPTIONS.edgeToNodeSpacing,
|
|
103
|
+
handleNudgingDistance: opts?.handleSpacing ?? DEFAULT_OPTIONS.handleSpacing,
|
|
104
|
+
|
|
105
|
+
// Routing parameters
|
|
106
|
+
segmentPenalty: opts?.segmentPenalty,
|
|
107
|
+
anglePenalty: opts?.anglePenalty,
|
|
108
|
+
crossingPenalty: opts?.crossingPenalty,
|
|
109
|
+
clusterCrossingPenalty: opts?.clusterCrossingPenalty,
|
|
110
|
+
fixedSharedPathPenalty: opts?.fixedSharedPathPenalty,
|
|
111
|
+
portDirectionPenalty: opts?.portDirectionPenalty,
|
|
112
|
+
reverseDirectionPenalty: opts?.reverseDirectionPenalty,
|
|
113
|
+
|
|
114
|
+
// Routing options
|
|
115
|
+
nudgeOrthogonalSegmentsConnectedToShapes: opts?.nudgeOrthogonalSegmentsConnectedToShapes,
|
|
116
|
+
nudgeSharedPathsWithCommonEndPoint: opts?.nudgeSharedPathsWithCommonEndPoint,
|
|
117
|
+
performUnifyingNudgingPreprocessingStep: opts?.performUnifyingNudgingPreprocessingStep,
|
|
118
|
+
nudgeOrthogonalTouchingColinearSegments: opts?.nudgeOrthogonalTouchingColinearSegments,
|
|
119
|
+
improveHyperedgeRoutesMovingJunctions: opts?.improveHyperedgeRoutesMovingJunctions,
|
|
120
|
+
penaliseOrthogonalSharedPathsAtConnEnds: opts?.penaliseOrthogonalSharedPathsAtConnEnds,
|
|
121
|
+
improveHyperedgeRoutesMovingAddingAndDeletingJunctions: opts?.improveHyperedgeRoutesMovingAddingAndDeletingJunctions,
|
|
122
|
+
|
|
123
|
+
// Connector settings
|
|
124
|
+
connectorType: opts?.connectorType ?? "orthogonal",
|
|
125
|
+
hateCrossings: opts?.hateCrossings,
|
|
126
|
+
pinInsideOffset: opts?.pinInsideOffset,
|
|
127
|
+
|
|
128
|
+
// Rendering
|
|
129
|
+
edgeRounding: opts?.edgeRounding ?? DEFAULT_OPTIONS.edgeRounding,
|
|
130
|
+
diagramGridSize: opts?.diagramGridSize ?? DEFAULT_OPTIONS.diagramGridSize,
|
|
131
|
+
shouldSplitEdgesNearHandle: opts?.shouldSplitEdgesNearHandle ?? DEFAULT_OPTIONS.shouldSplitEdgesNearHandle,
|
|
132
|
+
stubSize: opts?.stubSize,
|
|
133
|
+
autoBestSideConnection: opts?.autoBestSideConnection ?? DEFAULT_OPTIONS.autoBestSideConnection,
|
|
134
|
+
debounceMs: opts?.debounceMs ?? DEFAULT_OPTIONS.debounceMs,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function useEdgeRouting(
|
|
139
|
+
nodes: Node[],
|
|
140
|
+
edges: Edge[],
|
|
141
|
+
options?: UseEdgeRoutingOptions
|
|
142
|
+
): UseEdgeRoutingResult {
|
|
143
|
+
const nodesRef = useRef<Node[]>(nodes);
|
|
144
|
+
const edgesRef = useRef<Edge[]>(edges);
|
|
145
|
+
const opts = toRouterOptions(options);
|
|
146
|
+
const optsRef = useRef<AvoidRouterOptions>(opts);
|
|
147
|
+
const enrichNodeRef = useRef(options?.enrichNode);
|
|
148
|
+
const realTimeRoutingRef = useRef(options?.realTimeRouting ?? false);
|
|
149
|
+
|
|
150
|
+
nodesRef.current = nodes;
|
|
151
|
+
edgesRef.current = edges;
|
|
152
|
+
optsRef.current = opts;
|
|
153
|
+
enrichNodeRef.current = options?.enrichNode;
|
|
154
|
+
realTimeRoutingRef.current = options?.realTimeRouting ?? false;
|
|
155
|
+
|
|
156
|
+
const setRoutes = useEdgeRoutingStore((s) => s.setRoutes);
|
|
157
|
+
const setConnectorType = useEdgeRoutingStore((s) => s.setConnectorType);
|
|
158
|
+
const setDraggingNodeIds = useEdgeRoutingStore((s) => s.setDraggingNodeIds);
|
|
159
|
+
const setActions = useEdgeRoutingActionsStore((s) => s.setActions);
|
|
160
|
+
|
|
161
|
+
// Keep store in sync with current connector type
|
|
162
|
+
const connType = opts.connectorType ?? "orthogonal";
|
|
163
|
+
if (connType) setConnectorType(connType);
|
|
164
|
+
|
|
165
|
+
const { post, workerLoaded } = useRoutingWorker({ create: true });
|
|
166
|
+
|
|
167
|
+
const didResetRef = useRef(false);
|
|
168
|
+
const nodesMeasuredRef = useRef(false);
|
|
169
|
+
|
|
170
|
+
const sendReset = useCallback(() => {
|
|
171
|
+
if (!workerLoaded) return;
|
|
172
|
+
const nodes = nodesRef.current;
|
|
173
|
+
// Wait until all non-group nodes are measured (groups expand from children)
|
|
174
|
+
const nonGroupNodes = nodes.filter((n) => n.type !== "group");
|
|
175
|
+
const hasMeasured = nonGroupNodes.length === 0 || nonGroupNodes.every((n) => n.measured?.width != null || (n.style as { width?: number } | undefined)?.width != null);
|
|
176
|
+
if (!hasMeasured) return;
|
|
177
|
+
nodesMeasuredRef.current = true;
|
|
178
|
+
const edges = edgesRef.current;
|
|
179
|
+
if (edges.length === 0) {
|
|
180
|
+
setRoutes({});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Enrich nodes with _handlePins and _extraHeight on main thread
|
|
184
|
+
const enrich = enrichNodeRef.current;
|
|
185
|
+
const enrichedNodes = enrich ? nodes.map(enrich) : nodes;
|
|
186
|
+
post({
|
|
187
|
+
command: "reset",
|
|
188
|
+
nodes: enrichedNodes as unknown as FlowNode[],
|
|
189
|
+
edges: edges as unknown as FlowEdge[],
|
|
190
|
+
options: optsRef.current,
|
|
191
|
+
});
|
|
192
|
+
didResetRef.current = true;
|
|
193
|
+
}, [post, setRoutes, workerLoaded]);
|
|
194
|
+
|
|
195
|
+
// Full reset on position changes — nodesRef.current must have updated positions
|
|
196
|
+
// by the time this fires (ensured by the debounce + rAF delay).
|
|
197
|
+
const sendIncrementalChanges = useCallback(
|
|
198
|
+
(_nodeIds: string[]) => { sendReset(); },
|
|
199
|
+
[sendReset]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
203
|
+
const pendingChangeIdsRef = useRef<Set<string>>(new Set());
|
|
204
|
+
|
|
205
|
+
const resetRouting = useCallback(() => { sendReset(); }, [sendReset]);
|
|
206
|
+
const refreshRouting = useCallback(() => { sendReset(); }, [sendReset]);
|
|
207
|
+
const updateRoutingForNodeIds = useCallback(
|
|
208
|
+
(nodeIds: string[]) => { sendIncrementalChanges(nodeIds); },
|
|
209
|
+
[sendIncrementalChanges]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const updateRoutingOnNodesChange = useCallback(
|
|
213
|
+
(changes: NodeChange<Node>[]) => {
|
|
214
|
+
if (!workerLoaded) return;
|
|
215
|
+
|
|
216
|
+
let hasPosition = false;
|
|
217
|
+
let hasDimensions = false;
|
|
218
|
+
let hasAddOrRemove = false;
|
|
219
|
+
let isDragging = false;
|
|
220
|
+
const draggingNodeIds: string[] = [];
|
|
221
|
+
|
|
222
|
+
for (const c of changes) {
|
|
223
|
+
if (c.type === "position") {
|
|
224
|
+
hasPosition = true;
|
|
225
|
+
pendingChangeIdsRef.current.add(c.id);
|
|
226
|
+
if ((c as { dragging?: boolean }).dragging) {
|
|
227
|
+
isDragging = true;
|
|
228
|
+
draggingNodeIds.push(c.id);
|
|
229
|
+
}
|
|
230
|
+
} else if (c.type === "dimensions") {
|
|
231
|
+
hasDimensions = true;
|
|
232
|
+
pendingChangeIdsRef.current.add(c.id);
|
|
233
|
+
} else if (c.type === "add" || c.type === "remove") {
|
|
234
|
+
hasAddOrRemove = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!hasPosition && !hasDimensions && !hasAddOrRemove) return;
|
|
239
|
+
|
|
240
|
+
// Track which nodes are being dragged so useRoutedEdgePath shows fallback
|
|
241
|
+
if (isDragging) {
|
|
242
|
+
setDraggingNodeIds(new Set(draggingNodeIds));
|
|
243
|
+
} else if (hasPosition) {
|
|
244
|
+
// Position change without dragging = drag ended
|
|
245
|
+
setDraggingNodeIds(new Set());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const realTime = realTimeRoutingRef.current;
|
|
249
|
+
|
|
250
|
+
const needsFullReset = hasAddOrRemove || (hasDimensions && !nodesMeasuredRef.current);
|
|
251
|
+
|
|
252
|
+
if (needsFullReset) {
|
|
253
|
+
if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; }
|
|
254
|
+
pendingChangeIdsRef.current.clear();
|
|
255
|
+
debounceRef.current = setTimeout(() => {
|
|
256
|
+
debounceRef.current = null;
|
|
257
|
+
requestAnimationFrame(() => sendReset());
|
|
258
|
+
}, DEBOUNCE_ROUTING_MS);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!didResetRef.current) return;
|
|
263
|
+
|
|
264
|
+
// Without real-time: skip re-routing while dragging, wait for drag end
|
|
265
|
+
if (isDragging && !realTime) return;
|
|
266
|
+
|
|
267
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
268
|
+
debounceRef.current = setTimeout(() => {
|
|
269
|
+
debounceRef.current = null;
|
|
270
|
+
requestAnimationFrame(() => {
|
|
271
|
+
const ids = Array.from(pendingChangeIdsRef.current);
|
|
272
|
+
pendingChangeIdsRef.current.clear();
|
|
273
|
+
if (ids.length > 0) {
|
|
274
|
+
sendIncrementalChanges(ids);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}, DEBOUNCE_ROUTING_MS);
|
|
278
|
+
},
|
|
279
|
+
[workerLoaded, sendReset, sendIncrementalChanges]
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
setActions({
|
|
284
|
+
resetRouting,
|
|
285
|
+
updateRoutesForNodeId: (nodeId) => updateRoutingForNodeIds([nodeId]),
|
|
286
|
+
});
|
|
287
|
+
return () => setActions({ resetRouting: () => {}, updateRoutesForNodeId: () => {} });
|
|
288
|
+
}, [resetRouting, updateRoutingForNodeIds, setActions]);
|
|
289
|
+
|
|
290
|
+
// Re-route when options change. We serialize to JSON for stable comparison.
|
|
291
|
+
const optsJson = JSON.stringify(opts);
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (workerLoaded) sendReset();
|
|
294
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
295
|
+
}, [workerLoaded, nodes.length, edges.length, optsJson, sendReset]);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
updateRoutingOnNodesChange,
|
|
299
|
+
resetRouting,
|
|
300
|
+
refreshRouting,
|
|
301
|
+
updateRoutingForNodeIds,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { getStraightPath, getSmoothStepPath, getBezierPath, Position as RFPosition } from "@xyflow/react";
|
|
3
|
+
import { useEdgeRoutingStore } from "./edge-routing-store";
|
|
4
|
+
import { EDGE_BORDER_RADIUS } from "./constants";
|
|
5
|
+
import type { ConnectorType } from "./routing-core";
|
|
6
|
+
|
|
7
|
+
export type Position = "left" | "right" | "top" | "bottom";
|
|
8
|
+
|
|
9
|
+
const RF_POS: Record<Position, RFPosition> = {
|
|
10
|
+
left: "left" as RFPosition,
|
|
11
|
+
right: "right" as RFPosition,
|
|
12
|
+
top: "top" as RFPosition,
|
|
13
|
+
bottom: "bottom" as RFPosition,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface UseRoutedEdgePathParams {
|
|
17
|
+
id: string;
|
|
18
|
+
sourceX: number;
|
|
19
|
+
sourceY: number;
|
|
20
|
+
targetX: number;
|
|
21
|
+
targetY: number;
|
|
22
|
+
sourcePosition?: Position;
|
|
23
|
+
targetPosition?: Position;
|
|
24
|
+
borderRadius?: number;
|
|
25
|
+
offset?: number;
|
|
26
|
+
connectorType?: ConnectorType;
|
|
27
|
+
source?: string;
|
|
28
|
+
target?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getFallback(
|
|
32
|
+
sourceX: number, sourceY: number,
|
|
33
|
+
targetX: number, targetY: number,
|
|
34
|
+
sourcePosition: Position | undefined,
|
|
35
|
+
targetPosition: Position | undefined,
|
|
36
|
+
connectorType: ConnectorType | undefined,
|
|
37
|
+
borderRadius: number | undefined,
|
|
38
|
+
offset: number | undefined,
|
|
39
|
+
): [string, number, number, ...unknown[]] {
|
|
40
|
+
if (sourcePosition && targetPosition) {
|
|
41
|
+
const srcPos = RF_POS[sourcePosition];
|
|
42
|
+
const tgtPos = RF_POS[targetPosition];
|
|
43
|
+
|
|
44
|
+
if (connectorType === "bezier") {
|
|
45
|
+
return getBezierPath({ sourceX, sourceY, targetX, targetY, sourcePosition: srcPos, targetPosition: tgtPos });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return getSmoothStepPath({
|
|
49
|
+
sourceX, sourceY, targetX, targetY,
|
|
50
|
+
sourcePosition: srcPos, targetPosition: tgtPos,
|
|
51
|
+
borderRadius: borderRadius ?? EDGE_BORDER_RADIUS,
|
|
52
|
+
offset: offset ?? 20,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return getStraightPath({ sourceX, sourceY, targetX, targetY });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns [path, labelX, labelY, wasRouted] for a routed edge.
|
|
61
|
+
*
|
|
62
|
+
* - While a connected node is being dragged → dashed fallback
|
|
63
|
+
* - If a routed path exists for this edge → solid routed path
|
|
64
|
+
* - No route yet (worker hasn't responded) → dashed fallback
|
|
65
|
+
*/
|
|
66
|
+
export function useRoutedEdgePath(
|
|
67
|
+
params: UseRoutedEdgePathParams
|
|
68
|
+
): [path: string, labelX: number, labelY: number, wasRouted: boolean] {
|
|
69
|
+
const {
|
|
70
|
+
id, sourceX, sourceY, targetX, targetY,
|
|
71
|
+
sourcePosition, targetPosition,
|
|
72
|
+
offset, connectorType, source, target,
|
|
73
|
+
} = params;
|
|
74
|
+
|
|
75
|
+
const route = useEdgeRoutingStore((s) => s.routes[id]);
|
|
76
|
+
const draggingNodeIds = useEdgeRoutingStore((s) => s.draggingNodeIds);
|
|
77
|
+
|
|
78
|
+
return useMemo(() => {
|
|
79
|
+
// If a connected node is being dragged, show fallback
|
|
80
|
+
const isDragging =
|
|
81
|
+
draggingNodeIds.size > 0 &&
|
|
82
|
+
((source != null && draggingNodeIds.has(source)) ||
|
|
83
|
+
(target != null && draggingNodeIds.has(target)));
|
|
84
|
+
|
|
85
|
+
if (isDragging) {
|
|
86
|
+
const [path, lx, ly] = getFallback(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, connectorType, params.borderRadius, offset);
|
|
87
|
+
return [path, lx, ly, false];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// If we have a routed path, use it (no stale check — trust the router)
|
|
91
|
+
if (route?.path) {
|
|
92
|
+
return [route.path, route.labelX, route.labelY, true];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No route yet — show fallback
|
|
96
|
+
const [path, lx, ly] = getFallback(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, connectorType, params.borderRadius, offset);
|
|
97
|
+
return [path, lx, ly, false];
|
|
98
|
+
}, [route, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, offset, connectorType, params.borderRadius, source, target, draggingNodeIds]);
|
|
99
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { EdgeRoutingWorkerCommand } from "./worker-messages";
|
|
3
|
+
import type { AvoidRoute } from "./routing-core";
|
|
4
|
+
import { attachWorkerListener } from "./worker-listener";
|
|
5
|
+
|
|
6
|
+
export interface UseRoutingWorkerOptions {
|
|
7
|
+
create?: boolean;
|
|
8
|
+
onRouted?: (routes: Record<string, AvoidRoute>) => void;
|
|
9
|
+
onLoaded?: (success: boolean) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseRoutingWorkerResult {
|
|
13
|
+
workerLoaded: boolean;
|
|
14
|
+
post: (cmd: EdgeRoutingWorkerCommand) => void;
|
|
15
|
+
close: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates the edge-routing Web Worker and waits for it to load WASM.
|
|
20
|
+
* WASM loads exclusively in the worker thread — never on the main thread.
|
|
21
|
+
*/
|
|
22
|
+
export function useRoutingWorker(options?: UseRoutingWorkerOptions): UseRoutingWorkerResult {
|
|
23
|
+
const workerRef = useRef<Worker | null>(null);
|
|
24
|
+
const [workerLoaded, setWorkerLoaded] = useState(false);
|
|
25
|
+
const onRoutedRef = useRef(options?.onRouted);
|
|
26
|
+
const onLoadedRef = useRef(options?.onLoaded);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
onRoutedRef.current = options?.onRouted;
|
|
29
|
+
onLoadedRef.current = options?.onLoaded;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const createWorker = options?.create !== false;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!createWorker) {
|
|
36
|
+
console.log("[edge-routing] createWorker=false, skipping");
|
|
37
|
+
workerRef.current = null;
|
|
38
|
+
setWorkerLoaded(false);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log("[edge-routing] Creating worker...");
|
|
42
|
+
let worker: Worker;
|
|
43
|
+
try {
|
|
44
|
+
worker = new Worker(
|
|
45
|
+
new URL("./edge-routing.worker.ts", import.meta.url),
|
|
46
|
+
{ type: "module" },
|
|
47
|
+
);
|
|
48
|
+
console.log("[edge-routing] Worker created successfully (inline URL)");
|
|
49
|
+
console.log("[edge-routing] Worker created successfully");
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error("[edge-routing] Failed to create worker:", e);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
workerRef.current = worker;
|
|
56
|
+
|
|
57
|
+
worker.addEventListener("error", (e) => {
|
|
58
|
+
console.error("[edge-routing] Worker error event:", e.message, e);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
worker.addEventListener("messageerror", (e) => {
|
|
62
|
+
console.error("[edge-routing] Worker messageerror:", e);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const cleanup = attachWorkerListener(worker, {
|
|
66
|
+
onRouted: (routes) => {
|
|
67
|
+
const keys = Object.keys(routes);
|
|
68
|
+
console.log("[edge-routing] Routed", keys.length, "edges, ids:", keys, "sample path:", routes[keys[0]]?.path?.substring(0, 80));
|
|
69
|
+
onRoutedRef.current?.(routes);
|
|
70
|
+
},
|
|
71
|
+
onLoaded: (success) => {
|
|
72
|
+
console.log("[edge-routing] Worker WASM loaded:", success);
|
|
73
|
+
setWorkerLoaded(success);
|
|
74
|
+
onLoadedRef.current?.(success);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const onBeforeUnload = () => {
|
|
79
|
+
worker.postMessage({ command: "close" } as EdgeRoutingWorkerCommand);
|
|
80
|
+
worker.terminate();
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
86
|
+
cleanup();
|
|
87
|
+
worker.postMessage({ command: "close" } as EdgeRoutingWorkerCommand);
|
|
88
|
+
worker.terminate();
|
|
89
|
+
workerRef.current = null;
|
|
90
|
+
setWorkerLoaded(false);
|
|
91
|
+
};
|
|
92
|
+
}, [createWorker]);
|
|
93
|
+
|
|
94
|
+
const post = useCallback((cmd: EdgeRoutingWorkerCommand) => {
|
|
95
|
+
if (workerRef.current) {
|
|
96
|
+
workerRef.current.postMessage(cmd);
|
|
97
|
+
}
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const close = useCallback(() => {
|
|
101
|
+
if (workerRef.current) {
|
|
102
|
+
workerRef.current.postMessage({ command: "close" } as EdgeRoutingWorkerCommand);
|
|
103
|
+
workerRef.current.terminate();
|
|
104
|
+
workerRef.current = null;
|
|
105
|
+
setWorkerLoaded(false);
|
|
106
|
+
}
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
return { workerLoaded, post, close };
|
|
110
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listener for edge-routing Web Worker messages.
|
|
3
|
+
* Syncs "loaded" / "routed" into the edge routing store.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AvoidRoute } from "./routing-core";
|
|
7
|
+
import type { EdgeRoutingWorkerResponse } from "./worker-messages";
|
|
8
|
+
import { useEdgeRoutingStore } from "./edge-routing-store";
|
|
9
|
+
|
|
10
|
+
export interface AttachWorkerListenerOptions {
|
|
11
|
+
onRouted?: (routes: Record<string, AvoidRoute>) => void;
|
|
12
|
+
onLoaded?: (success: boolean) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function attachWorkerListener(
|
|
16
|
+
worker: Worker,
|
|
17
|
+
options: AttachWorkerListenerOptions = {}
|
|
18
|
+
): () => void {
|
|
19
|
+
const { onRouted, onLoaded } = options;
|
|
20
|
+
const setLoaded = useEdgeRoutingStore.getState().setLoaded;
|
|
21
|
+
const setRoutes = useEdgeRoutingStore.getState().setRoutes;
|
|
22
|
+
|
|
23
|
+
const handler = (e: MessageEvent<EdgeRoutingWorkerResponse>) => {
|
|
24
|
+
const msg = e.data;
|
|
25
|
+
if (!msg || typeof msg !== "object" || !("command" in msg)) return;
|
|
26
|
+
|
|
27
|
+
switch (msg.command) {
|
|
28
|
+
case "loaded":
|
|
29
|
+
setLoaded(msg.success);
|
|
30
|
+
onLoaded?.(msg.success);
|
|
31
|
+
break;
|
|
32
|
+
case "routed": {
|
|
33
|
+
// Merge new routes with existing ones so edges that failed
|
|
34
|
+
// to route this cycle keep their last good path
|
|
35
|
+
const prev = useEdgeRoutingStore.getState().routes;
|
|
36
|
+
const merged = { ...prev, ...msg.routes };
|
|
37
|
+
setRoutes(merged);
|
|
38
|
+
onRouted?.(merged);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
worker.addEventListener("message", handler);
|
|
47
|
+
return () => worker.removeEventListener("message", handler);
|
|
48
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message types for the edge-routing Web Worker.
|
|
3
|
+
* Main thread posts commands; worker posts back 'loaded' and 'routed'.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AvoidRoute, AvoidRouterOptions, FlowNode, FlowEdge } from "./routing-core";
|
|
7
|
+
|
|
8
|
+
/** Commands the main thread can send to the worker */
|
|
9
|
+
export type EdgeRoutingWorkerCommand =
|
|
10
|
+
| { command: "reset"; nodes: FlowNode[]; edges: FlowEdge[]; options?: AvoidRouterOptions }
|
|
11
|
+
| { command: "change"; cell: FlowNode | FlowEdge }
|
|
12
|
+
| { command: "remove"; id: string }
|
|
13
|
+
| { command: "add"; cell: FlowNode | FlowEdge }
|
|
14
|
+
| { command: "route"; nodes: FlowNode[]; edges: FlowEdge[]; options?: AvoidRouterOptions }
|
|
15
|
+
| { command: "updateNodes"; nodes: FlowNode[] }
|
|
16
|
+
| { command: "close" };
|
|
17
|
+
|
|
18
|
+
/** Messages the worker sends back to the main thread */
|
|
19
|
+
export type EdgeRoutingWorkerResponse =
|
|
20
|
+
| { command: "loaded"; success: boolean }
|
|
21
|
+
| { command: "routed"; routes: Record<string, AvoidRoute> };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polyfill for Web Worker: `window` is not available in workers, only `self`.
|
|
3
|
+
* Some libraries (e.g. libavoid-js, Emscripten WASM) reference `window` directly.
|
|
4
|
+
* This must be imported FIRST in any worker that loads such libraries.
|
|
5
|
+
*/
|
|
6
|
+
if (typeof window === "undefined" && typeof self !== "undefined") {
|
|
7
|
+
(self as unknown as Record<string, unknown>).window = self;
|
|
8
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"module": "esnext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"lib": ["ES2017", "DOM"],
|
|
13
|
+
"skipLibCheck": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|