@ngx-km/path-finding 0.0.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/README.md +39 -0
- package/fesm2022/ngx-km-path-finding.mjs +1002 -0
- package/fesm2022/ngx-km-path-finding.mjs.map +1 -0
- package/index.d.ts +465 -0
- package/package.json +23 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Injectable } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Path Finding Models
|
|
6
|
+
*
|
|
7
|
+
* Types and interfaces for obstacle-aware path routing between nodes.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Default path finding options
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_PATH_FINDING_OPTIONS = {
|
|
13
|
+
pathType: 'orthogonal',
|
|
14
|
+
obstaclePadding: 20,
|
|
15
|
+
cornerPadding: 10,
|
|
16
|
+
connectionSpacing: 15,
|
|
17
|
+
allowOverlap: false,
|
|
18
|
+
spreadAnchors: true,
|
|
19
|
+
anchorSpacing: 15,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Path Utilities
|
|
24
|
+
*
|
|
25
|
+
* Functions for path manipulation, simplification, and measurement.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Calculate the length of a route using Manhattan distance (orthogonal segments)
|
|
29
|
+
* Used for comparing route candidates
|
|
30
|
+
*/
|
|
31
|
+
function calculateRouteLength(points) {
|
|
32
|
+
let length = 0;
|
|
33
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
34
|
+
length += Math.abs(points[i + 1].x - points[i].x) + Math.abs(points[i + 1].y - points[i].y);
|
|
35
|
+
}
|
|
36
|
+
return length;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Calculate total path length using Euclidean distance
|
|
40
|
+
* Excludes control points for bezier curves
|
|
41
|
+
*/
|
|
42
|
+
function calculatePathLength(waypoints) {
|
|
43
|
+
let length = 0;
|
|
44
|
+
const points = waypoints.filter((w) => !w.isControlPoint);
|
|
45
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
46
|
+
length += Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
|
|
47
|
+
}
|
|
48
|
+
return length;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Calculate the midpoint of a path
|
|
52
|
+
* Returns the point at half the total path length
|
|
53
|
+
*/
|
|
54
|
+
function calculateMidpoint(waypoints) {
|
|
55
|
+
if (waypoints.length === 0)
|
|
56
|
+
return { x: 0, y: 0 };
|
|
57
|
+
if (waypoints.length === 1)
|
|
58
|
+
return { x: waypoints[0].x, y: waypoints[0].y };
|
|
59
|
+
const nonControlPoints = waypoints.filter((w) => !w.isControlPoint);
|
|
60
|
+
if (nonControlPoints.length >= 2) {
|
|
61
|
+
const totalLength = calculatePathLength(nonControlPoints);
|
|
62
|
+
const targetLength = totalLength / 2;
|
|
63
|
+
let accumulatedLength = 0;
|
|
64
|
+
for (let i = 0; i < nonControlPoints.length - 1; i++) {
|
|
65
|
+
const segmentLength = Math.hypot(nonControlPoints[i + 1].x - nonControlPoints[i].x, nonControlPoints[i + 1].y - nonControlPoints[i].y);
|
|
66
|
+
if (accumulatedLength + segmentLength >= targetLength) {
|
|
67
|
+
const remainingLength = targetLength - accumulatedLength;
|
|
68
|
+
const ratio = segmentLength > 0 ? remainingLength / segmentLength : 0;
|
|
69
|
+
return {
|
|
70
|
+
x: nonControlPoints[i].x + (nonControlPoints[i + 1].x - nonControlPoints[i].x) * ratio,
|
|
71
|
+
y: nonControlPoints[i].y + (nonControlPoints[i + 1].y - nonControlPoints[i].y) * ratio,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
accumulatedLength += segmentLength;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const midIndex = Math.floor(nonControlPoints.length / 2);
|
|
78
|
+
return { x: nonControlPoints[midIndex].x, y: nonControlPoints[midIndex].y };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Simplify path by removing collinear points
|
|
82
|
+
* This reduces redundant waypoints while preserving path shape
|
|
83
|
+
*/
|
|
84
|
+
function simplifyPath(waypoints) {
|
|
85
|
+
if (waypoints.length <= 2)
|
|
86
|
+
return waypoints;
|
|
87
|
+
const simplified = [waypoints[0]];
|
|
88
|
+
for (let i = 1; i < waypoints.length - 1; i++) {
|
|
89
|
+
const prev = simplified[simplified.length - 1];
|
|
90
|
+
const curr = waypoints[i];
|
|
91
|
+
const next = waypoints[i + 1];
|
|
92
|
+
const isCollinear = (Math.abs(prev.x - curr.x) < 0.001 && Math.abs(curr.x - next.x) < 0.001) ||
|
|
93
|
+
(Math.abs(prev.y - curr.y) < 0.001 && Math.abs(curr.y - next.y) < 0.001);
|
|
94
|
+
if (!isCollinear) {
|
|
95
|
+
simplified.push(curr);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
simplified.push(waypoints[waypoints.length - 1]);
|
|
99
|
+
return simplified;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Anchor Utilities
|
|
104
|
+
*
|
|
105
|
+
* Functions for calculating connection points and anchor slot assignments.
|
|
106
|
+
*/
|
|
107
|
+
/**
|
|
108
|
+
* Get preferred connection side based on relative position of nodes
|
|
109
|
+
*/
|
|
110
|
+
function getPreferredSide(from, to) {
|
|
111
|
+
const fromCenterX = from.x + from.width / 2;
|
|
112
|
+
const fromCenterY = from.y + from.height / 2;
|
|
113
|
+
const toCenterX = to.x + to.width / 2;
|
|
114
|
+
const toCenterY = to.y + to.height / 2;
|
|
115
|
+
const dx = toCenterX - fromCenterX;
|
|
116
|
+
const dy = toCenterY - fromCenterY;
|
|
117
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
118
|
+
return dx > 0 ? 'right' : 'left';
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get connection point on a node
|
|
126
|
+
*/
|
|
127
|
+
function getConnectionPoint(node, targetNode, forcedAnchor, cornerPadding, slot, anchorSpacing) {
|
|
128
|
+
const side = forcedAnchor || getPreferredSide(node, targetNode);
|
|
129
|
+
const point = getPointOnSide(node, side, cornerPadding, slot, anchorSpacing);
|
|
130
|
+
return { side, x: point.x, y: point.y };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get point on a specific side of a node
|
|
134
|
+
* Uses even distribution when multiple slots are on the same side
|
|
135
|
+
*/
|
|
136
|
+
function getPointOnSide(node, side, cornerPadding, slot, _anchorSpacing // kept for API compatibility but not used
|
|
137
|
+
) {
|
|
138
|
+
// Calculate position on edge using even distribution
|
|
139
|
+
const isVerticalSide = side === 'left' || side === 'right';
|
|
140
|
+
const edgeLength = isVerticalSide ? node.height : node.width;
|
|
141
|
+
const usableLength = edgeLength - 2 * cornerPadding;
|
|
142
|
+
let edgePosition;
|
|
143
|
+
if (slot && slot.totalSlots > 1) {
|
|
144
|
+
// Distribute evenly: for N slots, divide into N+1 segments
|
|
145
|
+
const t = (slot.slotIndex + 1) / (slot.totalSlots + 1);
|
|
146
|
+
edgePosition = cornerPadding + t * usableLength;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Single slot or no slot - use center
|
|
150
|
+
edgePosition = edgeLength / 2;
|
|
151
|
+
}
|
|
152
|
+
switch (side) {
|
|
153
|
+
case 'top':
|
|
154
|
+
return {
|
|
155
|
+
x: node.x + edgePosition,
|
|
156
|
+
y: node.y,
|
|
157
|
+
};
|
|
158
|
+
case 'bottom':
|
|
159
|
+
return {
|
|
160
|
+
x: node.x + edgePosition,
|
|
161
|
+
y: node.y + node.height,
|
|
162
|
+
};
|
|
163
|
+
case 'left':
|
|
164
|
+
return {
|
|
165
|
+
x: node.x,
|
|
166
|
+
y: node.y + edgePosition,
|
|
167
|
+
};
|
|
168
|
+
case 'right':
|
|
169
|
+
return {
|
|
170
|
+
x: node.x + node.width,
|
|
171
|
+
y: node.y + edgePosition,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Get a padded point - sits at padding distance from node edge
|
|
177
|
+
* Uses even distribution when multiple slots are on the same side
|
|
178
|
+
*/
|
|
179
|
+
function getPaddedPoint(node, side, padding, cornerPadding, slot) {
|
|
180
|
+
// Calculate position on edge using even distribution
|
|
181
|
+
const isVerticalSide = side === 'left' || side === 'right';
|
|
182
|
+
const edgeLength = isVerticalSide ? node.height : node.width;
|
|
183
|
+
const usableLength = edgeLength - 2 * cornerPadding;
|
|
184
|
+
let edgePosition;
|
|
185
|
+
if (slot && slot.totalSlots > 1) {
|
|
186
|
+
// Distribute evenly: for N slots, divide into N+1 segments
|
|
187
|
+
const t = (slot.slotIndex + 1) / (slot.totalSlots + 1);
|
|
188
|
+
edgePosition = cornerPadding + t * usableLength;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Single slot or no slot - use center
|
|
192
|
+
edgePosition = edgeLength / 2;
|
|
193
|
+
}
|
|
194
|
+
switch (side) {
|
|
195
|
+
case 'top': {
|
|
196
|
+
const edgeX = node.x + edgePosition;
|
|
197
|
+
return {
|
|
198
|
+
x: edgeX,
|
|
199
|
+
y: node.y - padding,
|
|
200
|
+
side,
|
|
201
|
+
nodeEdgeX: edgeX,
|
|
202
|
+
nodeEdgeY: node.y,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
case 'bottom': {
|
|
206
|
+
const edgeX = node.x + edgePosition;
|
|
207
|
+
return {
|
|
208
|
+
x: edgeX,
|
|
209
|
+
y: node.y + node.height + padding,
|
|
210
|
+
side,
|
|
211
|
+
nodeEdgeX: edgeX,
|
|
212
|
+
nodeEdgeY: node.y + node.height,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
case 'left': {
|
|
216
|
+
const edgeY = node.y + edgePosition;
|
|
217
|
+
return {
|
|
218
|
+
x: node.x - padding,
|
|
219
|
+
y: edgeY,
|
|
220
|
+
side,
|
|
221
|
+
nodeEdgeX: node.x,
|
|
222
|
+
nodeEdgeY: edgeY,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
case 'right': {
|
|
226
|
+
const edgeY = node.y + edgePosition;
|
|
227
|
+
return {
|
|
228
|
+
x: node.x + node.width + padding,
|
|
229
|
+
y: edgeY,
|
|
230
|
+
side,
|
|
231
|
+
nodeEdgeX: node.x + node.width,
|
|
232
|
+
nodeEdgeY: edgeY,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Calculate actual slot positions on a node edge
|
|
239
|
+
* Uses even distribution across the available edge space
|
|
240
|
+
*/
|
|
241
|
+
function calculateSlotPositions(node, side, totalSlots, cornerPadding = 5) {
|
|
242
|
+
const positions = [];
|
|
243
|
+
// Calculate the available range on this edge
|
|
244
|
+
const isVerticalSide = side === 'left' || side === 'right';
|
|
245
|
+
const edgeLength = isVerticalSide ? node.height : node.width;
|
|
246
|
+
const usableLength = edgeLength - 2 * cornerPadding;
|
|
247
|
+
for (let i = 0; i < totalSlots; i++) {
|
|
248
|
+
// Distribute evenly: for N slots, divide into N+1 segments
|
|
249
|
+
// and place slots at positions 1, 2, ..., N
|
|
250
|
+
const t = (i + 1) / (totalSlots + 1);
|
|
251
|
+
const offset = cornerPadding + t * usableLength;
|
|
252
|
+
switch (side) {
|
|
253
|
+
case 'top':
|
|
254
|
+
positions.push({ x: node.x + offset, y: node.y });
|
|
255
|
+
break;
|
|
256
|
+
case 'bottom':
|
|
257
|
+
positions.push({ x: node.x + offset, y: node.y + node.height });
|
|
258
|
+
break;
|
|
259
|
+
case 'left':
|
|
260
|
+
positions.push({ x: node.x, y: node.y + offset });
|
|
261
|
+
break;
|
|
262
|
+
case 'right':
|
|
263
|
+
positions.push({ x: node.x + node.width, y: node.y + offset });
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return positions;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Find optimal slot assignment to minimize crossings
|
|
271
|
+
* Returns an array where assignment[connIndex] = slotIndex
|
|
272
|
+
*/
|
|
273
|
+
function findOptimalSlotAssignment(connections, slotPositions) {
|
|
274
|
+
const n = connections.length;
|
|
275
|
+
// For small numbers, try all permutations to find the best one
|
|
276
|
+
if (n <= 6) {
|
|
277
|
+
return findBestPermutation(connections, slotPositions);
|
|
278
|
+
}
|
|
279
|
+
// For larger numbers, use greedy assignment based on distance
|
|
280
|
+
return greedySlotAssignment(connections, slotPositions);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Try all permutations to find the one with minimum total distance
|
|
284
|
+
*/
|
|
285
|
+
function findBestPermutation(connections, slotPositions) {
|
|
286
|
+
const n = connections.length;
|
|
287
|
+
const indices = Array.from({ length: n }, (_, i) => i);
|
|
288
|
+
let bestAssignment = indices.slice();
|
|
289
|
+
let bestScore = Infinity;
|
|
290
|
+
// Generate all permutations
|
|
291
|
+
const permute = (arr, start) => {
|
|
292
|
+
if (start === arr.length - 1) {
|
|
293
|
+
// Calculate score for this permutation
|
|
294
|
+
let totalDist = 0;
|
|
295
|
+
for (let i = 0; i < n; i++) {
|
|
296
|
+
const slot = slotPositions[arr[i]];
|
|
297
|
+
const conn = connections[i];
|
|
298
|
+
totalDist += Math.hypot(conn.otherNodeX - slot.x, conn.otherNodeY - slot.y);
|
|
299
|
+
}
|
|
300
|
+
if (totalDist < bestScore) {
|
|
301
|
+
bestScore = totalDist;
|
|
302
|
+
bestAssignment = arr.slice();
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
for (let i = start; i < arr.length; i++) {
|
|
307
|
+
[arr[start], arr[i]] = [arr[i], arr[start]];
|
|
308
|
+
permute(arr, start + 1);
|
|
309
|
+
[arr[start], arr[i]] = [arr[i], arr[start]];
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
permute(indices, 0);
|
|
313
|
+
return bestAssignment;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Greedy slot assignment for larger numbers of connections
|
|
317
|
+
*/
|
|
318
|
+
function greedySlotAssignment(connections, slotPositions) {
|
|
319
|
+
const n = connections.length;
|
|
320
|
+
const assignment = new Array(n).fill(-1);
|
|
321
|
+
const usedSlots = new Set();
|
|
322
|
+
// Create distance matrix
|
|
323
|
+
const distances = [];
|
|
324
|
+
for (let c = 0; c < n; c++) {
|
|
325
|
+
for (let s = 0; s < n; s++) {
|
|
326
|
+
const dist = Math.hypot(connections[c].otherNodeX - slotPositions[s].x, connections[c].otherNodeY - slotPositions[s].y);
|
|
327
|
+
distances.push({ connIdx: c, slotIdx: s, dist });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Sort by distance and greedily assign
|
|
331
|
+
distances.sort((a, b) => a.dist - b.dist);
|
|
332
|
+
for (const { connIdx, slotIdx } of distances) {
|
|
333
|
+
if (assignment[connIdx] === -1 && !usedSlots.has(slotIdx)) {
|
|
334
|
+
assignment[connIdx] = slotIdx;
|
|
335
|
+
usedSlots.add(slotIdx);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return assignment;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Calculate anchor slot assignments based on actual path results
|
|
342
|
+
* This uses the sides that were actually chosen by the routing algorithm
|
|
343
|
+
*/
|
|
344
|
+
function calculateAnchorSlotsFromPaths(paths, nodeMap) {
|
|
345
|
+
const slots = new Map();
|
|
346
|
+
// Group connections by node and side
|
|
347
|
+
const connectionsByNodeSide = new Map();
|
|
348
|
+
for (const path of paths) {
|
|
349
|
+
const sourceNode = nodeMap.get(path.sourceId);
|
|
350
|
+
const targetNode = nodeMap.get(path.targetId);
|
|
351
|
+
if (!sourceNode || !targetNode)
|
|
352
|
+
continue;
|
|
353
|
+
// Use the actual sides from the calculated path
|
|
354
|
+
const sourceSide = path.sourcePoint.side;
|
|
355
|
+
const targetSide = path.targetPoint.side;
|
|
356
|
+
// Source connection
|
|
357
|
+
const sourceKey = `${path.sourceId}:${sourceSide}`;
|
|
358
|
+
if (!connectionsByNodeSide.has(sourceKey)) {
|
|
359
|
+
connectionsByNodeSide.set(sourceKey, []);
|
|
360
|
+
}
|
|
361
|
+
connectionsByNodeSide.get(sourceKey).push({
|
|
362
|
+
relationshipId: path.relationshipId,
|
|
363
|
+
isSource: true,
|
|
364
|
+
otherNodeX: targetNode.x + targetNode.width / 2,
|
|
365
|
+
otherNodeY: targetNode.y + targetNode.height / 2,
|
|
366
|
+
});
|
|
367
|
+
// Target connection
|
|
368
|
+
const targetKey = `${path.targetId}:${targetSide}`;
|
|
369
|
+
if (!connectionsByNodeSide.has(targetKey)) {
|
|
370
|
+
connectionsByNodeSide.set(targetKey, []);
|
|
371
|
+
}
|
|
372
|
+
connectionsByNodeSide.get(targetKey).push({
|
|
373
|
+
relationshipId: path.relationshipId,
|
|
374
|
+
isSource: false,
|
|
375
|
+
otherNodeX: sourceNode.x + sourceNode.width / 2,
|
|
376
|
+
otherNodeY: sourceNode.y + sourceNode.height / 2,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// Assign slots for each node-side combination
|
|
380
|
+
for (const [key, connections] of connectionsByNodeSide) {
|
|
381
|
+
const [nodeId, side] = key.split(':');
|
|
382
|
+
const node = nodeMap.get(nodeId);
|
|
383
|
+
if (!node)
|
|
384
|
+
continue;
|
|
385
|
+
const totalSlots = connections.length;
|
|
386
|
+
if (totalSlots === 1) {
|
|
387
|
+
// Single connection - just assign slot 0
|
|
388
|
+
const conn = connections[0];
|
|
389
|
+
const slotKey = `${conn.relationshipId}-${conn.isSource ? 'source' : 'target'}`;
|
|
390
|
+
slots.set(slotKey, {
|
|
391
|
+
nodeId,
|
|
392
|
+
side,
|
|
393
|
+
relationshipId: conn.relationshipId,
|
|
394
|
+
isSource: conn.isSource,
|
|
395
|
+
slotIndex: 0,
|
|
396
|
+
totalSlots: 1,
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
// Calculate the actual slot positions on the node edge
|
|
401
|
+
const slotPositions = calculateSlotPositions(node, side, totalSlots);
|
|
402
|
+
// Find optimal assignment: pair each connection with a slot position
|
|
403
|
+
// to minimize total distance and avoid crossings
|
|
404
|
+
const assignment = findOptimalSlotAssignment(connections, slotPositions);
|
|
405
|
+
// Assign slots based on the optimal pairing
|
|
406
|
+
assignment.forEach((slotIndex, connIndex) => {
|
|
407
|
+
const conn = connections[connIndex];
|
|
408
|
+
const slotKey = `${conn.relationshipId}-${conn.isSource ? 'source' : 'target'}`;
|
|
409
|
+
slots.set(slotKey, {
|
|
410
|
+
nodeId,
|
|
411
|
+
side,
|
|
412
|
+
relationshipId: conn.relationshipId,
|
|
413
|
+
isSource: conn.isSource,
|
|
414
|
+
slotIndex,
|
|
415
|
+
totalSlots,
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return slots;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Geometry Utilities
|
|
424
|
+
*
|
|
425
|
+
* Core geometric types and functions for path finding calculations.
|
|
426
|
+
*/
|
|
427
|
+
/**
|
|
428
|
+
* Clamp a value between min and max
|
|
429
|
+
*/
|
|
430
|
+
function clamp(value, min, max) {
|
|
431
|
+
return Math.max(min, Math.min(max, value));
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Create a Rect from a PathNode
|
|
435
|
+
*/
|
|
436
|
+
function createRect(node) {
|
|
437
|
+
return {
|
|
438
|
+
x: node.x,
|
|
439
|
+
y: node.y,
|
|
440
|
+
width: node.width,
|
|
441
|
+
height: node.height,
|
|
442
|
+
right: node.x + node.width,
|
|
443
|
+
bottom: node.y + node.height,
|
|
444
|
+
centerX: node.x + node.width / 2,
|
|
445
|
+
centerY: node.y + node.height / 2,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Create a padded Rect from a PathNode
|
|
450
|
+
*/
|
|
451
|
+
function createPaddedRect(node, padding) {
|
|
452
|
+
return {
|
|
453
|
+
x: node.x - padding,
|
|
454
|
+
y: node.y - padding,
|
|
455
|
+
width: node.width + padding * 2,
|
|
456
|
+
height: node.height + padding * 2,
|
|
457
|
+
right: node.x + node.width + padding,
|
|
458
|
+
bottom: node.y + node.height + padding,
|
|
459
|
+
centerX: node.x + node.width / 2,
|
|
460
|
+
centerY: node.y + node.height / 2,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Expand a Rect by padding amount on all sides
|
|
465
|
+
*/
|
|
466
|
+
function expandRect(rect, padding) {
|
|
467
|
+
return {
|
|
468
|
+
x: rect.x - padding,
|
|
469
|
+
y: rect.y - padding,
|
|
470
|
+
width: rect.width + padding * 2,
|
|
471
|
+
height: rect.height + padding * 2,
|
|
472
|
+
right: rect.right + padding,
|
|
473
|
+
bottom: rect.bottom + padding,
|
|
474
|
+
centerX: rect.centerX,
|
|
475
|
+
centerY: rect.centerY,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Check if a line segment passes through rectangle interior (not just touches edge)
|
|
480
|
+
* Used for obstacle collision detection in orthogonal routing
|
|
481
|
+
*/
|
|
482
|
+
function linePassesThroughRect(p1, p2, rect) {
|
|
483
|
+
// Check bounding box overlap first
|
|
484
|
+
const lineMinX = Math.min(p1.x, p2.x);
|
|
485
|
+
const lineMaxX = Math.max(p1.x, p2.x);
|
|
486
|
+
const lineMinY = Math.min(p1.y, p2.y);
|
|
487
|
+
const lineMaxY = Math.max(p1.y, p2.y);
|
|
488
|
+
// No overlap at all
|
|
489
|
+
if (lineMaxX <= rect.x || lineMinX >= rect.right)
|
|
490
|
+
return false;
|
|
491
|
+
if (lineMaxY <= rect.y || lineMinY >= rect.bottom)
|
|
492
|
+
return false;
|
|
493
|
+
// For orthogonal lines, check if line passes through interior
|
|
494
|
+
const isVertical = Math.abs(p1.x - p2.x) < 0.001;
|
|
495
|
+
const isHorizontal = Math.abs(p1.y - p2.y) < 0.001;
|
|
496
|
+
if (isVertical) {
|
|
497
|
+
// Vertical line: passes through if x is strictly inside rect
|
|
498
|
+
return p1.x > rect.x && p1.x < rect.right;
|
|
499
|
+
}
|
|
500
|
+
if (isHorizontal) {
|
|
501
|
+
// Horizontal line: passes through if y is strictly inside rect
|
|
502
|
+
return p1.y > rect.y && p1.y < rect.bottom;
|
|
503
|
+
}
|
|
504
|
+
// Diagonal line (shouldn't happen for orthogonal routing)
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Orthogonal Path Strategy
|
|
510
|
+
*
|
|
511
|
+
* Generates paths with right-angle turns only (Manhattan-style routing).
|
|
512
|
+
* Supports obstacle avoidance with multiple routing strategies.
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* Orthogonal path strategy - routes paths with right-angle turns
|
|
516
|
+
*/
|
|
517
|
+
class OrthogonalStrategy {
|
|
518
|
+
calculatePath(context) {
|
|
519
|
+
const { source, target, relationship, allNodes, options, sourceSlot, targetSlot } = context;
|
|
520
|
+
const padding = options.obstaclePadding;
|
|
521
|
+
// Create rectangles for collision detection
|
|
522
|
+
const sourceRect = createRect(source);
|
|
523
|
+
const targetRect = createRect(target);
|
|
524
|
+
// Get obstacles (all nodes except source and target, with padding)
|
|
525
|
+
const obstacles = options.allowOverlap
|
|
526
|
+
? []
|
|
527
|
+
: allNodes
|
|
528
|
+
.filter((n) => n.id !== source.id && n.id !== target.id)
|
|
529
|
+
.map((n) => createPaddedRect(n, padding));
|
|
530
|
+
// If we have slots from a previous pass, force those sides to maintain consistency
|
|
531
|
+
const forcedSourceSide = sourceSlot?.side ?? relationship.sourceAnchor;
|
|
532
|
+
const forcedTargetSide = targetSlot?.side ?? relationship.targetAnchor;
|
|
533
|
+
const bestRoute = this.findBestOrthogonalRoute(source, target, sourceRect, targetRect, obstacles, padding, forcedSourceSide, forcedTargetSide, options.cornerPadding, sourceSlot, targetSlot);
|
|
534
|
+
return {
|
|
535
|
+
sourcePoint: {
|
|
536
|
+
side: bestRoute.sourceSide,
|
|
537
|
+
x: bestRoute.waypoints[0].x,
|
|
538
|
+
y: bestRoute.waypoints[0].y,
|
|
539
|
+
},
|
|
540
|
+
targetPoint: {
|
|
541
|
+
side: bestRoute.targetSide,
|
|
542
|
+
x: bestRoute.waypoints[bestRoute.waypoints.length - 1].x,
|
|
543
|
+
y: bestRoute.waypoints[bestRoute.waypoints.length - 1].y,
|
|
544
|
+
},
|
|
545
|
+
waypoints: bestRoute.waypoints,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Find the best orthogonal route using padded-edge approach
|
|
550
|
+
*/
|
|
551
|
+
findBestOrthogonalRoute(sourceNode, targetNode, sourceRect, targetRect, obstacles, padding, forcedSourceSide, forcedTargetSide, cornerPadding, sourceSlot, targetSlot) {
|
|
552
|
+
const candidates = [];
|
|
553
|
+
// Determine which sides to try
|
|
554
|
+
const sourceSides = forcedSourceSide
|
|
555
|
+
? [forcedSourceSide]
|
|
556
|
+
: ['top', 'right', 'bottom', 'left'];
|
|
557
|
+
const targetSides = forcedTargetSide
|
|
558
|
+
? [forcedTargetSide]
|
|
559
|
+
: ['top', 'right', 'bottom', 'left'];
|
|
560
|
+
// Try all side combinations
|
|
561
|
+
for (const sourceSide of sourceSides) {
|
|
562
|
+
for (const targetSide of targetSides) {
|
|
563
|
+
// Get padded points (at padding distance from node edge)
|
|
564
|
+
const sourcePadded = getPaddedPoint(sourceNode, sourceSide, padding, cornerPadding, sourceSlot);
|
|
565
|
+
const targetPadded = getPaddedPoint(targetNode, targetSide, padding, cornerPadding, targetSlot);
|
|
566
|
+
// Find route between padded points
|
|
567
|
+
const paddedRoute = this.findPaddedRoute(sourcePadded, targetPadded, sourceRect, targetRect, obstacles, padding);
|
|
568
|
+
if (paddedRoute) {
|
|
569
|
+
// Extend route to actual node edges
|
|
570
|
+
const fullRoute = this.extendRouteToEdges(paddedRoute, sourcePadded, targetPadded);
|
|
571
|
+
const length = calculateRouteLength(fullRoute);
|
|
572
|
+
candidates.push({
|
|
573
|
+
waypoints: fullRoute,
|
|
574
|
+
length,
|
|
575
|
+
sourceSide,
|
|
576
|
+
targetSide,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Return the shortest valid route
|
|
582
|
+
if (candidates.length > 0) {
|
|
583
|
+
candidates.sort((a, b) => a.length - b.length);
|
|
584
|
+
return candidates[0];
|
|
585
|
+
}
|
|
586
|
+
// Fallback: simple direct route
|
|
587
|
+
const preferredSourceSide = forcedSourceSide || getPreferredSide(sourceNode, targetNode);
|
|
588
|
+
const preferredTargetSide = forcedTargetSide || getPreferredSide(targetNode, sourceNode);
|
|
589
|
+
const sourcePadded = getPaddedPoint(sourceNode, preferredSourceSide, padding, cornerPadding, sourceSlot);
|
|
590
|
+
const targetPadded = getPaddedPoint(targetNode, preferredTargetSide, padding, cornerPadding, targetSlot);
|
|
591
|
+
const fallbackRoute = this.createFallbackRoute(sourcePadded, targetPadded);
|
|
592
|
+
const fullRoute = this.extendRouteToEdges(fallbackRoute, sourcePadded, targetPadded);
|
|
593
|
+
return {
|
|
594
|
+
waypoints: fullRoute,
|
|
595
|
+
length: Infinity,
|
|
596
|
+
sourceSide: preferredSourceSide,
|
|
597
|
+
targetSide: preferredTargetSide,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Find route between padded points avoiding obstacles
|
|
602
|
+
*/
|
|
603
|
+
findPaddedRoute(source, target, sourceRect, targetRect, obstacles, padding) {
|
|
604
|
+
// Create padded obstacle rects for collision detection
|
|
605
|
+
// Include source and target nodes as obstacles (paths shouldn't go through them)
|
|
606
|
+
const paddedSourceRect = expandRect(sourceRect, padding);
|
|
607
|
+
const paddedTargetRect = expandRect(targetRect, padding);
|
|
608
|
+
const allObstacles = [...obstacles, paddedSourceRect, paddedTargetRect];
|
|
609
|
+
// Try strategies in order of simplicity
|
|
610
|
+
const strategies = [
|
|
611
|
+
() => this.tryDirect(source, target, allObstacles),
|
|
612
|
+
() => this.tryLShape(source, target, allObstacles),
|
|
613
|
+
() => this.tryZShape(source, target, allObstacles),
|
|
614
|
+
() => this.tryUShape(source, target, sourceRect, targetRect, allObstacles, padding),
|
|
615
|
+
];
|
|
616
|
+
for (const strategy of strategies) {
|
|
617
|
+
const route = strategy();
|
|
618
|
+
if (route) {
|
|
619
|
+
return simplifyPath(route);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Try direct route (straight line between padded points)
|
|
626
|
+
*/
|
|
627
|
+
tryDirect(source, target, obstacles) {
|
|
628
|
+
// Only works if horizontally or vertically aligned
|
|
629
|
+
const isHorizontal = Math.abs(source.y - target.y) < 1;
|
|
630
|
+
const isVertical = Math.abs(source.x - target.x) < 1;
|
|
631
|
+
if (!isHorizontal && !isVertical)
|
|
632
|
+
return null;
|
|
633
|
+
const route = [
|
|
634
|
+
{ x: source.x, y: source.y },
|
|
635
|
+
{ x: target.x, y: target.y },
|
|
636
|
+
];
|
|
637
|
+
if (this.isRouteValid(route, obstacles)) {
|
|
638
|
+
return route;
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Try L-shaped route (one bend)
|
|
644
|
+
*/
|
|
645
|
+
tryLShape(source, target, obstacles) {
|
|
646
|
+
const isHorizontalSource = source.side === 'left' || source.side === 'right';
|
|
647
|
+
const isHorizontalTarget = target.side === 'left' || target.side === 'right';
|
|
648
|
+
// L-route only works when source and target are on different axis
|
|
649
|
+
if (isHorizontalSource === isHorizontalTarget)
|
|
650
|
+
return null;
|
|
651
|
+
const routes = [];
|
|
652
|
+
if (isHorizontalSource) {
|
|
653
|
+
// Source horizontal, target vertical: go horizontal first, then vertical
|
|
654
|
+
routes.push([
|
|
655
|
+
{ x: source.x, y: source.y },
|
|
656
|
+
{ x: target.x, y: source.y },
|
|
657
|
+
{ x: target.x, y: target.y },
|
|
658
|
+
]);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
// Source vertical, target horizontal: go vertical first, then horizontal
|
|
662
|
+
routes.push([
|
|
663
|
+
{ x: source.x, y: source.y },
|
|
664
|
+
{ x: source.x, y: target.y },
|
|
665
|
+
{ x: target.x, y: target.y },
|
|
666
|
+
]);
|
|
667
|
+
}
|
|
668
|
+
for (const route of routes) {
|
|
669
|
+
if (this.isRouteValid(route, obstacles)) {
|
|
670
|
+
return route;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Try Z-shaped route (two bends)
|
|
677
|
+
*/
|
|
678
|
+
tryZShape(source, target, obstacles) {
|
|
679
|
+
const isHorizontalSource = source.side === 'left' || source.side === 'right';
|
|
680
|
+
const isHorizontalTarget = target.side === 'left' || target.side === 'right';
|
|
681
|
+
const routes = [];
|
|
682
|
+
if (isHorizontalSource && isHorizontalTarget) {
|
|
683
|
+
// Both horizontal: route with vertical middle segment
|
|
684
|
+
const midX = (source.x + target.x) / 2;
|
|
685
|
+
routes.push([
|
|
686
|
+
{ x: source.x, y: source.y },
|
|
687
|
+
{ x: midX, y: source.y },
|
|
688
|
+
{ x: midX, y: target.y },
|
|
689
|
+
{ x: target.x, y: target.y },
|
|
690
|
+
]);
|
|
691
|
+
}
|
|
692
|
+
else if (!isHorizontalSource && !isHorizontalTarget) {
|
|
693
|
+
// Both vertical: route with horizontal middle segment
|
|
694
|
+
const midY = (source.y + target.y) / 2;
|
|
695
|
+
routes.push([
|
|
696
|
+
{ x: source.x, y: source.y },
|
|
697
|
+
{ x: source.x, y: midY },
|
|
698
|
+
{ x: target.x, y: midY },
|
|
699
|
+
{ x: target.x, y: target.y },
|
|
700
|
+
]);
|
|
701
|
+
}
|
|
702
|
+
// Sort by length and return first valid
|
|
703
|
+
routes.sort((a, b) => calculateRouteLength(a) - calculateRouteLength(b));
|
|
704
|
+
for (const route of routes) {
|
|
705
|
+
if (this.isRouteValid(route, obstacles)) {
|
|
706
|
+
return route;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Try U-shaped route (goes around nodes)
|
|
713
|
+
*/
|
|
714
|
+
tryUShape(source, target, sourceRect, targetRect, obstacles, padding) {
|
|
715
|
+
const routes = [];
|
|
716
|
+
// Calculate bounds for going around
|
|
717
|
+
const minX = Math.min(sourceRect.x, targetRect.x) - padding * 2;
|
|
718
|
+
const maxX = Math.max(sourceRect.right, targetRect.right) + padding * 2;
|
|
719
|
+
const minY = Math.min(sourceRect.y, targetRect.y) - padding * 2;
|
|
720
|
+
const maxY = Math.max(sourceRect.bottom, targetRect.bottom) + padding * 2;
|
|
721
|
+
// Route around top
|
|
722
|
+
routes.push([
|
|
723
|
+
{ x: source.x, y: source.y },
|
|
724
|
+
{ x: source.x, y: minY },
|
|
725
|
+
{ x: target.x, y: minY },
|
|
726
|
+
{ x: target.x, y: target.y },
|
|
727
|
+
]);
|
|
728
|
+
// Route around bottom
|
|
729
|
+
routes.push([
|
|
730
|
+
{ x: source.x, y: source.y },
|
|
731
|
+
{ x: source.x, y: maxY },
|
|
732
|
+
{ x: target.x, y: maxY },
|
|
733
|
+
{ x: target.x, y: target.y },
|
|
734
|
+
]);
|
|
735
|
+
// Route around left
|
|
736
|
+
routes.push([
|
|
737
|
+
{ x: source.x, y: source.y },
|
|
738
|
+
{ x: minX, y: source.y },
|
|
739
|
+
{ x: minX, y: target.y },
|
|
740
|
+
{ x: target.x, y: target.y },
|
|
741
|
+
]);
|
|
742
|
+
// Route around right
|
|
743
|
+
routes.push([
|
|
744
|
+
{ x: source.x, y: source.y },
|
|
745
|
+
{ x: maxX, y: source.y },
|
|
746
|
+
{ x: maxX, y: target.y },
|
|
747
|
+
{ x: target.x, y: target.y },
|
|
748
|
+
]);
|
|
749
|
+
// Sort by length and return first valid
|
|
750
|
+
routes.sort((a, b) => calculateRouteLength(a) - calculateRouteLength(b));
|
|
751
|
+
for (const route of routes) {
|
|
752
|
+
if (this.isRouteValid(route, obstacles)) {
|
|
753
|
+
return route;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Create fallback route when no valid route is found
|
|
760
|
+
*/
|
|
761
|
+
createFallbackRoute(source, target) {
|
|
762
|
+
const isHorizontalSource = source.side === 'left' || source.side === 'right';
|
|
763
|
+
if (isHorizontalSource) {
|
|
764
|
+
return [
|
|
765
|
+
{ x: source.x, y: source.y },
|
|
766
|
+
{ x: target.x, y: source.y },
|
|
767
|
+
{ x: target.x, y: target.y },
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
return [
|
|
772
|
+
{ x: source.x, y: source.y },
|
|
773
|
+
{ x: source.x, y: target.y },
|
|
774
|
+
{ x: target.x, y: target.y },
|
|
775
|
+
];
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Extend route from padded points to actual node edges
|
|
780
|
+
*/
|
|
781
|
+
extendRouteToEdges(paddedRoute, source, target) {
|
|
782
|
+
const result = [];
|
|
783
|
+
// Add start point at node edge
|
|
784
|
+
result.push({ x: source.nodeEdgeX, y: source.nodeEdgeY });
|
|
785
|
+
// Add all waypoints from padded route
|
|
786
|
+
for (const wp of paddedRoute) {
|
|
787
|
+
result.push({ x: wp.x, y: wp.y });
|
|
788
|
+
}
|
|
789
|
+
// Add end point at node edge
|
|
790
|
+
result.push({ x: target.nodeEdgeX, y: target.nodeEdgeY });
|
|
791
|
+
return simplifyPath(result);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Check if route is valid (doesn't pass through obstacles)
|
|
795
|
+
*/
|
|
796
|
+
isRouteValid(route, obstacles) {
|
|
797
|
+
for (let i = 0; i < route.length - 1; i++) {
|
|
798
|
+
const p1 = route[i];
|
|
799
|
+
const p2 = route[i + 1];
|
|
800
|
+
for (const obs of obstacles) {
|
|
801
|
+
if (linePassesThroughRect(p1, p2, obs)) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Bezier Path Strategy
|
|
812
|
+
*
|
|
813
|
+
* Generates smooth curved paths using cubic bezier curves.
|
|
814
|
+
* Control points are positioned based on connection sides.
|
|
815
|
+
*/
|
|
816
|
+
/**
|
|
817
|
+
* Bezier path strategy - creates smooth curved paths
|
|
818
|
+
*/
|
|
819
|
+
class BezierStrategy {
|
|
820
|
+
calculatePath(context) {
|
|
821
|
+
const { source, target, relationship, options, sourceSlot, targetSlot } = context;
|
|
822
|
+
const sourcePoint = getConnectionPoint(source, target, relationship.sourceAnchor, options.cornerPadding, sourceSlot, options.anchorSpacing);
|
|
823
|
+
const targetPoint = getConnectionPoint(target, source, relationship.targetAnchor, options.cornerPadding, targetSlot, options.anchorSpacing);
|
|
824
|
+
const waypoints = this.calculateBezierPath(sourcePoint, targetPoint);
|
|
825
|
+
return {
|
|
826
|
+
sourcePoint,
|
|
827
|
+
targetPoint,
|
|
828
|
+
waypoints,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Calculate bezier path with control points
|
|
833
|
+
*/
|
|
834
|
+
calculateBezierPath(source, target) {
|
|
835
|
+
const controlDistance = Math.max(50, Math.hypot(target.x - source.x, target.y - source.y) * 0.3);
|
|
836
|
+
const sourceControl = this.getControlPointForBezier(source, controlDistance);
|
|
837
|
+
const targetControl = this.getControlPointForBezier(target, controlDistance);
|
|
838
|
+
return [
|
|
839
|
+
{ x: source.x, y: source.y },
|
|
840
|
+
{ x: sourceControl.x, y: sourceControl.y, isControlPoint: true },
|
|
841
|
+
{ x: targetControl.x, y: targetControl.y, isControlPoint: true },
|
|
842
|
+
{ x: target.x, y: target.y },
|
|
843
|
+
];
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Get control point position based on connection side
|
|
847
|
+
*/
|
|
848
|
+
getControlPointForBezier(connection, distance) {
|
|
849
|
+
switch (connection.side) {
|
|
850
|
+
case 'top':
|
|
851
|
+
return { x: connection.x, y: connection.y - distance };
|
|
852
|
+
case 'bottom':
|
|
853
|
+
return { x: connection.x, y: connection.y + distance };
|
|
854
|
+
case 'left':
|
|
855
|
+
return { x: connection.x - distance, y: connection.y };
|
|
856
|
+
case 'right':
|
|
857
|
+
return { x: connection.x + distance, y: connection.y };
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Straight Path Strategy
|
|
864
|
+
*
|
|
865
|
+
* Generates direct line paths between nodes.
|
|
866
|
+
* Simple and fast, but may overlap obstacles.
|
|
867
|
+
*/
|
|
868
|
+
/**
|
|
869
|
+
* Straight path strategy - creates direct line paths
|
|
870
|
+
*/
|
|
871
|
+
class StraightStrategy {
|
|
872
|
+
calculatePath(context) {
|
|
873
|
+
const { source, target, relationship, options, sourceSlot, targetSlot } = context;
|
|
874
|
+
const sourcePoint = getConnectionPoint(source, target, relationship.sourceAnchor, options.cornerPadding, sourceSlot, options.anchorSpacing);
|
|
875
|
+
const targetPoint = getConnectionPoint(target, source, relationship.targetAnchor, options.cornerPadding, targetSlot, options.anchorSpacing);
|
|
876
|
+
const waypoints = [
|
|
877
|
+
{ x: sourcePoint.x, y: sourcePoint.y },
|
|
878
|
+
{ x: targetPoint.x, y: targetPoint.y },
|
|
879
|
+
];
|
|
880
|
+
return {
|
|
881
|
+
sourcePoint,
|
|
882
|
+
targetPoint,
|
|
883
|
+
waypoints,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Path Finding Service
|
|
890
|
+
*
|
|
891
|
+
* Calculates obstacle-aware paths between nodes using smart routing.
|
|
892
|
+
* Automatically selects optimal connection sides to find shortest valid paths.
|
|
893
|
+
*
|
|
894
|
+
* Uses strategy pattern for different path types:
|
|
895
|
+
* - Orthogonal: Right-angle turns with obstacle avoidance
|
|
896
|
+
* - Bezier: Smooth curved paths
|
|
897
|
+
* - Straight: Direct line paths
|
|
898
|
+
*/
|
|
899
|
+
class PathFindingService {
|
|
900
|
+
strategies = {
|
|
901
|
+
orthogonal: new OrthogonalStrategy(),
|
|
902
|
+
bezier: new BezierStrategy(),
|
|
903
|
+
straight: new StraightStrategy(),
|
|
904
|
+
};
|
|
905
|
+
/**
|
|
906
|
+
* Calculate paths for all relationships
|
|
907
|
+
*/
|
|
908
|
+
calculatePaths(input) {
|
|
909
|
+
const options = { ...DEFAULT_PATH_FINDING_OPTIONS, ...input.options };
|
|
910
|
+
const nodeMap = new Map();
|
|
911
|
+
for (const node of input.nodes) {
|
|
912
|
+
nodeMap.set(node.id, node);
|
|
913
|
+
}
|
|
914
|
+
// First pass: calculate paths without anchor spreading to determine actual sides
|
|
915
|
+
const initialPaths = [];
|
|
916
|
+
for (const relationship of input.relationships) {
|
|
917
|
+
const sourceNode = nodeMap.get(relationship.sourceId);
|
|
918
|
+
const targetNode = nodeMap.get(relationship.targetId);
|
|
919
|
+
if (!sourceNode || !targetNode) {
|
|
920
|
+
console.warn(`PathFindingService: Relationship "${relationship.id}" references unknown node`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const path = this.calculatePath(sourceNode, targetNode, relationship, input.nodes, options, undefined, undefined);
|
|
924
|
+
initialPaths.push(path);
|
|
925
|
+
}
|
|
926
|
+
// If anchor spreading is disabled, return initial paths
|
|
927
|
+
if (!options.spreadAnchors) {
|
|
928
|
+
return { paths: initialPaths };
|
|
929
|
+
}
|
|
930
|
+
// Second pass: calculate anchor slots based on actual sides used, then recalculate paths
|
|
931
|
+
const anchorSlots = calculateAnchorSlotsFromPaths(initialPaths, nodeMap);
|
|
932
|
+
const paths = [];
|
|
933
|
+
for (const relationship of input.relationships) {
|
|
934
|
+
const sourceNode = nodeMap.get(relationship.sourceId);
|
|
935
|
+
const targetNode = nodeMap.get(relationship.targetId);
|
|
936
|
+
if (!sourceNode || !targetNode) {
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const sourceSlot = anchorSlots.get(`${relationship.id}-source`);
|
|
940
|
+
const targetSlot = anchorSlots.get(`${relationship.id}-target`);
|
|
941
|
+
const path = this.calculatePath(sourceNode, targetNode, relationship, input.nodes, options, sourceSlot, targetSlot);
|
|
942
|
+
paths.push(path);
|
|
943
|
+
}
|
|
944
|
+
return { paths };
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Calculate a single path between two nodes
|
|
948
|
+
*/
|
|
949
|
+
calculatePath(source, target, relationship, allNodes, options, sourceSlot, targetSlot) {
|
|
950
|
+
const strategy = this.strategies[options.pathType];
|
|
951
|
+
const context = {
|
|
952
|
+
source,
|
|
953
|
+
target,
|
|
954
|
+
relationship,
|
|
955
|
+
allNodes,
|
|
956
|
+
options,
|
|
957
|
+
sourceSlot,
|
|
958
|
+
targetSlot,
|
|
959
|
+
};
|
|
960
|
+
const result = strategy.calculatePath(context);
|
|
961
|
+
const midpoint = calculateMidpoint(result.waypoints);
|
|
962
|
+
const length = calculatePathLength(result.waypoints);
|
|
963
|
+
return {
|
|
964
|
+
relationshipId: relationship.id,
|
|
965
|
+
sourceId: relationship.sourceId,
|
|
966
|
+
targetId: relationship.targetId,
|
|
967
|
+
sourcePoint: result.sourcePoint,
|
|
968
|
+
targetPoint: result.targetPoint,
|
|
969
|
+
waypoints: result.waypoints,
|
|
970
|
+
midpoint,
|
|
971
|
+
length,
|
|
972
|
+
pathType: options.pathType,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Recalculate paths (convenience method)
|
|
977
|
+
*/
|
|
978
|
+
recalculatePaths(input) {
|
|
979
|
+
return this.calculatePaths(input);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Calculate a single path (convenience method)
|
|
983
|
+
*/
|
|
984
|
+
calculateSinglePath(source, target, relationship, obstacles, options) {
|
|
985
|
+
const fullOptions = { ...DEFAULT_PATH_FINDING_OPTIONS, ...options };
|
|
986
|
+
return this.calculatePath(source, target, relationship, obstacles, fullOptions, undefined, undefined);
|
|
987
|
+
}
|
|
988
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: PathFindingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
989
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: PathFindingService });
|
|
990
|
+
}
|
|
991
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: PathFindingService, decorators: [{
|
|
992
|
+
type: Injectable
|
|
993
|
+
}] });
|
|
994
|
+
|
|
995
|
+
// Models - Values
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Generated bundle index. Do not edit.
|
|
999
|
+
*/
|
|
1000
|
+
|
|
1001
|
+
export { BezierStrategy, DEFAULT_PATH_FINDING_OPTIONS, OrthogonalStrategy, PathFindingService, StraightStrategy, calculateAnchorSlotsFromPaths, calculateMidpoint, calculatePathLength, calculateRouteLength, calculateSlotPositions, clamp, createPaddedRect, createRect, expandRect, findOptimalSlotAssignment, getConnectionPoint, getPaddedPoint, getPointOnSide, getPreferredSide, linePassesThroughRect, simplifyPath };
|
|
1002
|
+
//# sourceMappingURL=ngx-km-path-finding.mjs.map
|