@peter.prib/renderengine 0.2.6 → 0.2.10

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.
Files changed (78) hide show
  1. package/README.md +70 -9
  2. package/REFERENCE.md +303 -2
  3. package/dist/renderengine-workspace.umd.js +8851 -3305
  4. package/dist/renderengine.umd.js +5621 -1382
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +251 -19
  7. package/package.json +1 -1
  8. package/src/bian.js +182 -0
  9. package/src/charting/DiagramEditor.js +5 -26
  10. package/src/charting/DiagramTypeRegistry.js +8 -2
  11. package/src/charting/FormDialog.js +398 -385
  12. package/src/charting/ModelCheckDialog.js +63 -0
  13. package/src/charting/ModelChecker.js +26 -0
  14. package/src/charting/RowsField.js +127 -0
  15. package/src/charting/ViewTabStrip.js +94 -81
  16. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  17. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  18. package/src/charting/datamodel/ClassificationLens.js +33 -0
  19. package/src/charting/datamodel/ClassificationScheme.js +98 -0
  20. package/src/charting/datamodel/DataModel.js +574 -12
  21. package/src/charting/datamodel/DataModelChecker.js +338 -0
  22. package/src/charting/datamodel/DataModelCompared.js +127 -0
  23. package/src/charting/datamodel/DataModelContext.js +40 -10
  24. package/src/charting/datamodel/DataModelEditCommand.js +47 -0
  25. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  26. package/src/charting/datamodel/DataModelPerspective.js +55 -0
  27. package/src/charting/datamodel/DataType.js +108 -0
  28. package/src/charting/datamodel/EnumerationType.js +64 -0
  29. package/src/charting/datamodel/ServiceDomain.js +186 -0
  30. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  31. package/src/charting/datamodel/SqlDdl.js +80 -0
  32. package/src/charting/datamodel/SqlDdlFileFormat.js +23 -0
  33. package/src/charting/datamodel/StructuredType.js +63 -0
  34. package/src/charting/datamodel/index.js +21 -5
  35. package/src/charting/datamodel/naming/CamelCaseStyle.js +13 -0
  36. package/src/charting/datamodel/naming/LowerWordsStyle.js +13 -0
  37. package/src/charting/datamodel/naming/NameStyle.js +57 -0
  38. package/src/charting/datamodel/naming/NamingStandard.js +132 -0
  39. package/src/charting/datamodel/naming/PascalCaseStyle.js +13 -0
  40. package/src/charting/datamodel/naming/SnakeCaseStyle.js +13 -0
  41. package/src/charting/datamodel/naming/TitleCaseStyle.js +25 -0
  42. package/src/charting/datamodel/naming/UpperSnakeStyle.js +13 -0
  43. package/src/charting/datamodel/naming/index.js +8 -0
  44. package/src/charting/datamodel/scorecard/CompletenessCategory.js +30 -0
  45. package/src/charting/datamodel/scorecard/ConsistencyCategory.js +33 -0
  46. package/src/charting/datamodel/scorecard/DefinitionsCategory.js +33 -0
  47. package/src/charting/datamodel/scorecard/ModelScorecard.js +113 -0
  48. package/src/charting/datamodel/scorecard/ReadabilityCategory.js +37 -0
  49. package/src/charting/datamodel/scorecard/ScorecardCategory.js +46 -0
  50. package/src/charting/datamodel/scorecard/StandardsCategory.js +29 -0
  51. package/src/charting/datamodel/scorecard/StructureCategory.js +32 -0
  52. package/src/charting/datamodel/scorecard/index.js +8 -0
  53. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  54. package/src/charting/diagramModel/OpenViewList.js +75 -0
  55. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  56. package/src/charting/diagrams/DataModelDiagram.js +4 -11
  57. package/src/charting/diagrams/DataModelRelationshipStrategy.js +19 -5
  58. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  59. package/src/charting/diagrams/GraphDiagram.js +11 -1
  60. package/src/charting/diagrams/NodeGeometry.js +173 -158
  61. package/src/charting/glyphs/PersonGlyph.js +24 -23
  62. package/src/charting/toolbar/ToolbarSelect.js +6 -0
  63. package/src/datamodel.js +402 -4
  64. package/src/index.js +6 -4
  65. package/src/workspace/EditorShell.js +166 -67
  66. package/src/workspace/PanelLayout.js +56 -0
  67. package/src/workspace/layout/TabsNode.js +205 -191
  68. package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
  69. package/src/workspace/parts/DataModelPart.js +534 -33
  70. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  71. package/src/workspace/parts/EditorPart.js +461 -407
  72. package/src/workspace/parts/ModelFile.js +216 -187
  73. package/src/workspace/parts/ModelsPart.js +483 -192
  74. package/src/workspace/storage/BackupFolder.js +145 -0
  75. package/src/workspace/storage/KeyValueStore.js +92 -0
  76. package/src/workspace/storage/ModelLibrary.js +65 -0
  77. package/src/workspace/workArea.js +417 -413
  78. package/src/yaml/YamlReader.js +372 -0
@@ -1,553 +1,578 @@
1
- import { NodeGeometry } from './NodeGeometry.js';
2
-
3
- const SIDE_VECTORS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
4
-
5
- /**
6
- * EdgeGeometry — pure, stateless helpers for graph edge anchors, routing,
7
- * waypoints, and path generation.
8
- *
9
- * These used to live on RenderEngine; they are collected here so diagram
10
- * classes can compute edge geometry without going through the engine façade.
11
- */
12
- export class EdgeGeometry {
13
- /** Pure. The point (and outward unit normal) where an edge attaches to a
14
- * node's bounding box for a given `side`: 'top'/'right'/'bottom'/'left'
15
- * pins the anchor to a fixed spot on the node — it stays there as the
16
- * node moves, instead of sliding around the node to keep facing whatever
17
- * it's connected to. `'auto'` (default) keeps that older sliding
18
- * behavior, using `ux,uy` (a unit vector already pointing at the other
19
- * endpoint) as the direction to project out to the node's edge.
20
- *
21
- * `rectBoundary` (true for `shape:'rect'`/`'table'`, false otherwise —
22
- * see computeEdgePath's callers) picks WHICH boundary 'auto' projects
23
- * onto: for a circle/person node (always drawn as a true circle, square
24
- * bounding box or not) a fixed-radius circle is exactly its visible
25
- * edge, but for a rect/table node that same circle radius (using the
26
- * LARGER of w/h) massively overshoots a box that's wide but short —
27
- * visually "the line stops in empty space well outside the rectangle"
28
- * instead of touching it. `rectBoundary` instead finds where the ray
29
- * from centre exits the actual w x h box (whichever of the top/bottom
30
- * or left/right edges it reaches first). */
31
- static edgeAnchor(pos, size, side, ux, uy, rectBoundary, shapeType = null) {
32
- const hw = size.w / 2, hh = size.h / 2;
33
- switch (side) {
34
- case 'top': return { x: pos.x, y: pos.y - hh, nx: 0, ny: -1 };
35
- case 'bottom': return { x: pos.x, y: pos.y + hh, nx: 0, ny: 1 };
36
- case 'left': return { x: pos.x - hw, y: pos.y, nx: -1, ny: 0 };
37
- case 'right': return { x: pos.x + hw, y: pos.y, nx: 1, ny: 0 };
38
- default: {
39
- if (rectBoundary) {
40
- const tx = ux !== 0 ? hw / Math.abs(ux) : Infinity;
41
- const ty = uy !== 0 ? hh / Math.abs(uy) : Infinity;
42
- const t = Math.min(tx, ty);
43
- const safeT = Number.isFinite(t) ? t : 0;
44
- return { x: pos.x + ux * safeT, y: pos.y + uy * safeT, nx: ux, ny: uy };
45
- }
46
- // Diamond-specific anchoring: find intersection with actual diamond edges
47
- if (shapeType === 'diamond') {
48
- const t = EdgeGeometry._diamondBoundaryT(ux, uy, hw, hh);
49
- return { x: pos.x + ux * t, y: pos.y + uy * t, nx: ux, ny: uy };
50
- }
51
- // Default circle-based anchoring for other shapes
52
- const r = Math.max(size.w, size.h) / 2;
53
- return { x: pos.x + ux * r, y: pos.y + uy * r, nx: ux, ny: uy };
54
- }
55
- }
56
- }
57
-
58
- /** Pure. Given a unit direction vector (ux, uy) and diamond half-dimensions (hw, hh),
59
- * find the distance t such that (t*ux, t*uy) intersects the diamond's perimeter.
60
- * Diamond vertices are at (±hw, 0) and (0, ±hh), with perimeter equation |x|/hw + |y|/hh = 1.
61
- * For a ray at (t*ux, t*uy): t*(|ux|/hw + |uy|/hh) = 1 => t = 1 / (|ux|/hw + |uy|/hh) */
62
- static _diamondBoundaryT(ux, uy, hw, hh) {
63
- const absUx = Math.abs(ux), absUy = Math.abs(uy);
64
- if (absUx === 0 && absUy === 0) return 0; // Coincident nodes
65
- const denom = (absUx / hw) + (absUy / hh);
66
- return denom > 0 ? 1 / denom : Math.max(hw, hh);
67
- }
68
-
69
- /** Pure. Anchor for one end of an edge. A node that knows its own outline
70
- * supplies `info.boundaryPoint(dx, dy) → {x, y}` (DiagramEditor templates
71
- * do, rotation included), and the anchor is where the ray toward the other
72
- * end — or out of the pinned side — meets it. Otherwise falls back to the
73
- * shape-class model in edgeAnchor. */
74
- static nodeAnchor(info, pos, size, side, ux, uy, rectBoundary) {
75
- if (typeof info?.boundaryPoint !== 'function') {
76
- return EdgeGeometry.edgeAnchor(pos, size, side, ux, uy, rectBoundary, info?.shape);
77
- }
78
- const [nx, ny] = SIDE_VECTORS[side] ?? [ux, uy];
79
- const p = info.boundaryPoint(nx, ny);
80
- return { x: p.x, y: p.y, nx, ny };
81
- }
82
-
83
- /** Pure. The side of a `size.w × size.h` node at `pos` that faces
84
- * `toward`, comparing offsets relative to the node's proportions so a wide
85
- * box prefers its long sides. */
86
- static facingSide(pos, size, toward) {
87
- const dx = toward.x - pos.x, dy = toward.y - pos.y;
88
- if (Math.abs(dx) / (size.w || 1) >= Math.abs(dy) / (size.h || 1)) return dx >= 0 ? 'right' : 'left';
89
- return dy >= 0 ? 'bottom' : 'top';
90
- }
91
-
92
- /** Pure. Unit directions in which a path arrives at each end, pointing
93
- * into the node: `start` runs from the path's next point toward its first
94
- * point, `end` from the previous point toward its last. Works for every
95
- * path computeEdgePath emits (absolute M/L/C): the neighbouring coordinate
96
- * pair is the adjacent vertex or bezier control point, both tangent to
97
- * the end. Coincident points are skipped; {x:0,y:0} if all coincide. */
98
- static endDirections(d) {
99
- const nums = (d || '').match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)?.map(Number) ?? [];
100
- const pts = [];
101
- for (let i = 0; i + 1 < nums.length; i += 2) pts.push({ x: nums[i], y: nums[i + 1] });
102
- const toward = (tip, others) => {
103
- for (const p of others) {
104
- const dx = tip.x - p.x, dy = tip.y - p.y, len = Math.hypot(dx, dy);
105
- if (len > 1e-9) return { x: dx / len, y: dy / len };
106
- }
107
- return { x: 0, y: 0 };
108
- };
109
- if (!pts.length) return { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } };
110
- return {
111
- start: toward(pts[0], pts.slice(1)),
112
- end: toward(pts[pts.length - 1], pts.slice(0, -1).reverse()),
113
- };
114
- }
115
-
116
- /** Pure. 'h' when `side` pins the anchor to a vertical edge of the node's
117
- * box (left/right — the line touches it moving horizontally), 'v' for
118
- * top/bottom, null for 'auto' (no fixed orientation to route around). */
119
- static edgeSideOrientation(side) {
120
- if (side === 'left' || side === 'right') return 'h';
121
- if (side === 'top' || side === 'bottom') return 'v';
122
- return null;
123
- }
124
-
125
- /** Pure. Cubic-bezier path between two pinned/auto anchors, leaving and
126
- * arriving along each anchor's own normal (`nx,ny`) so a pinned side
127
- * produces a visibly perpendicular exit/entry instead of a straight
128
- * dog-leg. The generalized shape of the table-link curve in
129
- * computeEdgePath, usable for any node pair. */
130
- static curvedEdgePath(a1, a2) {
131
- const dist = Math.hypot(a2.x - a1.x, a2.y - a1.y) || 1;
132
- const k = Math.min(dist * 0.5, 80);
133
- const c1x = a1.x + a1.nx * k, c1y = a1.y + a1.ny * k;
134
- const c2x = a2.x + a2.nx * k, c2y = a2.y + a2.ny * k;
135
- return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d: `M${a1.x},${a1.y} C${c1x},${c1y} ${c2x},${c2y} ${a2.x},${a2.y}` };
136
- }
137
-
138
- /** Pure. Right-angle (Manhattan) path between two anchors. When a side is
139
- * pinned, the segment touching that anchor is forced to the matching
140
- * orientation (horizontal for left/right, vertical for top/bottom); with
141
- * both anchors 'auto' it just bends along whichever axis has the bigger
142
- * gap. Mixed orientations (e.g. source pinned right, target pinned top)
143
- * get a single-corner elbow instead of the usual midpoint jog, since a
144
- * midpoint jog can't satisfy two different orientations at once.
145
- *
146
- * With `obstacles` (array of {x,y,r} nodes), routes around blocking obstacles
147
- * using axis-aligned detour lanes. No avoidance for mixed-orientation elbows;
148
- * those are rare and have degenerate geometry. */
149
- static orthogonalEdgePath(a1, a2, sourceSide, targetSide, obstacles = []) {
150
- const so = EdgeGeometry.edgeSideOrientation(sourceSide);
151
- const to = EdgeGeometry.edgeSideOrientation(targetSide);
152
- let d;
153
- if (so && to && so !== to) {
154
- const corner = so === 'h' ? { x: a2.x, y: a1.y } : { x: a1.x, y: a2.y };
155
- d = `M${a1.x},${a1.y} L${corner.x},${corner.y} L${a2.x},${a2.y}`;
156
- } else {
157
- const horizontalFirst = so ? so === 'h' : (to ? to === 'h' : Math.abs(a2.x - a1.x) >= Math.abs(a2.y - a1.y));
158
- const blocking = EdgeGeometry.findBlockingObstacles(a1, a2, obstacles);
159
-
160
- if (blocking.length === 0) {
161
- // No obstacles — use the plain midpoint jog
162
- if (horizontalFirst) {
163
- const midX = (a1.x + a2.x) / 2;
164
- d = `M${a1.x},${a1.y} L${midX},${a1.y} L${midX},${a2.y} L${a2.x},${a2.y}`;
165
- } else {
166
- const midY = (a1.y + a2.y) / 2;
167
- d = `M${a1.x},${a1.y} L${a1.x},${midY} L${a2.x},${midY} L${a2.x},${a2.y}`;
168
- }
169
- } else {
170
- // Route around the obstacle cluster with a detour lane
171
- const bounds = EdgeGeometry.obstacleClusterBounds(blocking);
172
- if (horizontalFirst) {
173
- const naturalY = (a1.y + a2.y) / 2;
174
- const routeY = naturalY < bounds.minY ? bounds.minY - 12 : bounds.maxY + 12;
175
- d = `M${a1.x},${a1.y} L${a1.x},${routeY} L${a2.x},${routeY} L${a2.x},${a2.y}`;
176
- } else {
177
- const naturalX = (a1.x + a2.x) / 2;
178
- const routeX = naturalX < bounds.minX ? bounds.minX - 12 : bounds.maxX + 12;
179
- d = `M${a1.x},${a1.y} L${routeX},${a1.y} L${routeX},${a2.y} L${a2.x},${a2.y}`;
180
- }
181
- }
182
- }
183
- return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d };
184
- }
185
-
186
- /** Pure. True if the segment a1->a2 passes within `circle.r` of `circle`'s
187
- * centre (see pointToSegmentDist2 for the underlying clamped-distance math). */
188
- static segmentIntersectsCircle(a1, a2, circle) {
189
- return EdgeGeometry.pointToSegmentDist2(a1, a2, circle.x, circle.y) < circle.r * circle.r;
190
- }
191
-
192
- /** Pure. Find all obstacles that block the direct line from a1 to a2.
193
- * Returns array of blocking obstacles, sorted by their position along the line. */
194
- static findBlockingObstacles(a1, a2, obstacles = []) {
195
- return obstacles
196
- .filter(o => EdgeGeometry.segmentIntersectsCircle(a1, a2, o))
197
- .sort((o1, o2) => {
198
- const dx = a2.x - a1.x, dy = a2.y - a1.y;
199
- const len2 = dx * dx + dy * dy;
200
- const t1 = ((o1.x - a1.x) * dx + (o1.y - a1.y) * dy) / len2;
201
- const t2 = ((o2.x - a1.x) * dx + (o2.y - a1.y) * dy) / len2;
202
- return t1 - t2;
203
- });
204
- }
205
-
206
- /** Pure. Compute bounding box around a cluster of obstacles with clearance margin. */
207
- static obstacleClusterBounds(obstacles, margin = 12) {
208
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
209
- for (const o of obstacles) {
210
- minX = Math.min(minX, o.x - o.r - margin);
211
- minY = Math.min(minY, o.y - o.r - margin);
212
- maxX = Math.max(maxX, o.x + o.r + margin);
213
- maxY = Math.max(maxY, o.y + o.r + margin);
214
- }
215
- return { minX, minY, maxX, maxY };
216
- }
217
-
218
- /** Pure. Route around a cluster of obstacles by computing two waypoints (above/below or left/right).
219
- * Returns array of waypoints to thread between source and target anchors. */
220
- static routeAroundCluster(a1, a2, obstacles) {
221
- const bounds = EdgeGeometry.obstacleClusterBounds(obstacles);
222
- const dx = a2.x - a1.x, dy = a2.y - a1.y;
223
- const isHorizontal = Math.abs(dx) >= Math.abs(dy);
224
-
225
- // Choose routing direction: if more horizontal, route above/below; else left/right
226
- if (isHorizontal) {
227
- // Route vertically around the cluster
228
- const side = a1.y < bounds.minY ? -1 : 1; // above if source is above, else below
229
- const routeY = side > 0 ? bounds.maxY : bounds.minY;
230
- const midX = (bounds.minX + bounds.maxX) / 2;
231
- return [
232
- { x: midX, y: a1.y }, // move horizontally to cluster's horizontal center
233
- { x: midX, y: routeY }, // move vertically around cluster
234
- ];
235
- } else {
236
- // Route horizontally around the cluster
237
- const side = a1.x < bounds.minX ? -1 : 1; // left if source is left, else right
238
- const routeX = side > 0 ? bounds.maxX : bounds.minX;
239
- const midY = (bounds.minY + bounds.maxY) / 2;
240
- return [
241
- { x: a1.x, y: midY }, // move vertically to cluster's vertical center
242
- { x: routeX, y: midY }, // move horizontally around cluster
243
- ];
244
- }
245
- }
246
-
247
- /** Pure. Straight line between two anchors, unless `obstacles` (other
248
- * nodes as {x,y,r}, source/target already excluded by the caller) puts
249
- * one in the way. Routes around single obstacles with a perpendicular
250
- * waypoint; routes around clusters by threading around the bounding box.
251
- * Degrades to a plain straight line on an uncrowded graph.
252
- *
253
- * Note: This is 'polyline' routing only. 'curved' and 'orthogonal' modes
254
- * currently do not have obstacle avoidance — they route directly regardless
255
- * of obstacles. A caller needing avoidance on those modes should either:
256
- * 1. Switch to 'polyline' routing, or
257
- * 2. Manually add waypoints (edge.waypoints) to the edge data. */
258
- static polylineEdgePath(a1, a2, obstacles = []) {
259
- const blocking = EdgeGeometry.findBlockingObstacles(a1, a2, obstacles);
260
- if (blocking.length === 0) {
261
- return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d: `M${a1.x},${a1.y} L${a2.x},${a2.y}` };
262
- }
263
-
264
- let waypoints;
265
- if (blocking.length === 1) {
266
- // Single obstacle: use perpendicular offset (original behavior)
267
- const o = blocking[0];
268
- const dx = a2.x - a1.x, dy = a2.y - a1.y;
269
- const len = Math.hypot(dx, dy) || 1;
270
- const px = -dy / len, py = dx / len;
271
- const len2 = len * len;
272
- const t = ((o.x - a1.x) * dx + (o.y - a1.y) * dy) / len2;
273
- const footX = a1.x + dx * t, footY = a1.y + dy * t;
274
- const cross = dx * (o.y - a1.y) - dy * (o.x - a1.x);
275
- const side = cross >= 0 ? -1 : 1;
276
- const clearance = o.r + 12;
277
- waypoints = [{ x: footX + px * clearance * side, y: footY + py * clearance * side }];
278
- } else {
279
- // Multiple obstacles: route around the cluster
280
- waypoints = EdgeGeometry.routeAroundCluster(a1, a2, blocking);
281
- }
282
-
283
- const points = [a1, ...waypoints, a2];
284
- const d = points.map((pt, i) => `${i === 0 ? 'M' : 'L'}${pt.x},${pt.y}`).join(' ');
285
- return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d };
286
- }
287
-
288
- /** Pure. Given `points` (an ordered polyline — anchor, any existing
289
- * waypoints, anchor), finds which SEGMENT a click at `(x,y)` landed
290
- * closest to and returns that segment's starting index. Since
291
- * `points[0]` is the source anchor (not a stored waypoint), segment `i`
292
- * is exactly where a new `edge.waypoints[i]` belongs (`Array.splice(i, 0,
293
- * point)`) — see the double-click-to-add-a-bend handler in
294
- * RenderEngine's drag listeners. */
295
- static nearestSegmentInsertIndex(points, x, y) {
296
- let bestIdx = 0, bestDist2 = Infinity;
297
- for (let i = 0; i < points.length - 1; i++) {
298
- const dist2 = EdgeGeometry.pointToSegmentDist2(points[i], points[i + 1], x, y);
299
- if (dist2 < bestDist2) { bestDist2 = dist2; bestIdx = i; }
300
- }
301
- return bestIdx;
302
- }
303
-
304
- /** Pure. Detect if two line segments intersect. Returns intersection point if they do, null otherwise.
305
- * Used for edge-crossing visualization. */
306
- static lineSegmentIntersection(p1, p2, p3, p4) {
307
- const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y;
308
- const x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
309
-
310
- const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
311
- if (Math.abs(denom) < 1e-10) return null; // parallel or coincident
312
-
313
- const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
314
- const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
315
-
316
- if (t > 0 && t < 1 && u > 0 && u < 1) {
317
- return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
318
- }
319
- return null;
320
- }
321
-
322
- /** Pure. Find all crossing points between a given edge path and an array of other edge paths.
323
- * Returns array of {x, y, angle} crossing points. */
324
- static findEdgeCrossings(edgePath, otherEdgePaths) {
325
- const crossings = [];
326
- const points = EdgeGeometry.pathToPoints(edgePath);
327
-
328
- for (const otherPath of otherEdgePaths) {
329
- const otherPoints = EdgeGeometry.pathToPoints(otherPath);
330
- for (let i = 0; i < points.length - 1; i++) {
331
- for (let j = 0; j < otherPoints.length - 1; j++) {
332
- const intersection = EdgeGeometry.lineSegmentIntersection(
333
- points[i], points[i + 1],
334
- otherPoints[j], otherPoints[j + 1]
335
- );
336
- if (intersection) {
337
- const angle = Math.atan2(
338
- points[i + 1].y - points[i].y,
339
- points[i + 1].x - points[i].x
340
- );
341
- crossings.push({ ...intersection, angle });
342
- }
343
- }
344
- }
345
- }
346
- return crossings;
347
- }
348
-
349
- /** Pure. Convert SVG path string to array of points (samples bezier curves).
350
- * Used for edge-crossing detection. */
351
- static pathToPoints(pathStr) {
352
- const points = [];
353
- const tokens = pathStr.match(/[MmLlCc]|[+-]?[\d.]+/g) || [];
354
- let current = { x: 0, y: 0 };
355
- let i = 0;
356
-
357
- while (i < tokens.length) {
358
- const cmd = tokens[i];
359
- if (cmd === 'M' || cmd === 'm') {
360
- i++;
361
- const x = Number(tokens[i++]), y = Number(tokens[i++]);
362
- current = cmd === 'M' ? { x, y } : { x: current.x + x, y: current.y + y };
363
- points.push({ ...current });
364
- } else if (cmd === 'L' || cmd === 'l') {
365
- i++;
366
- const x = Number(tokens[i++]), y = Number(tokens[i++]);
367
- current = cmd === 'L' ? { x, y } : { x: current.x + x, y: current.y + y };
368
- points.push({ ...current });
369
- } else if (cmd === 'C' || cmd === 'c') {
370
- i++;
371
- const coords = [];
372
- while (i < tokens.length && !/[MmLlCc]/.test(tokens[i])) {
373
- coords.push(Number(tokens[i++]));
374
- }
375
- const p0 = current;
376
- const p1 = cmd === 'C' ? { x: coords[0], y: coords[1] } : { x: current.x + coords[0], y: current.y + coords[1] };
377
- const p2 = cmd === 'C' ? { x: coords[2], y: coords[3] } : { x: current.x + coords[2], y: current.y + coords[3] };
378
- const p3 = cmd === 'C' ? { x: coords[4], y: coords[5] } : { x: current.x + coords[4], y: current.y + coords[5] };
379
-
380
- for (let s = 1; s <= 8; s++) {
381
- const t = s / 8;
382
- const mt = 1 - t;
383
- const pt = {
384
- x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
385
- y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
386
- };
387
- points.push(pt);
388
- }
389
- current = p3;
390
- } else {
391
- i++;
392
- }
393
- }
394
- return points;
395
- }
396
-
397
- /** Pure. Returns a NEW `edge.waypoints` array with one more point added
398
- * at `point`, landing in the segment nearest it along the current route
399
- * sourcePos -> ...edge.waypoints -> targetPos (see nearestSegmentInsertIndex).
400
- * Used by the edge context menu's "Add Waypoint Here" — hand-routing used
401
- * to be reachable by double-clicking the edge line itself, but that
402
- * gesture now opens `onEdgeDoubleClick`'s properties dialog instead, so
403
- * adding a bend point moved to the context menu. */
404
- static edgeWaypointsWithInsertion(edge, sourcePos, targetPos, point) {
405
- const points = [sourcePos, ...(edge.waypoints || []), targetPos];
406
- const insertIdx = EdgeGeometry.nearestSegmentInsertIndex(points, point.x, point.y);
407
- const waypoints = edge.waypoints ? [...edge.waypoints] : [];
408
- waypoints.splice(insertIdx, 0, { x: point.x, y: point.y });
409
- return waypoints;
410
- }
411
-
412
- /** Pure. Squared distance from `(x,y)` to the closest point on segment
413
- * a->b (clamped to the segment, not the infinite line) — shared by
414
- * segmentIntersectsCircle's threshold test and nearestSegmentInsertIndex's
415
- * plain distance comparison. */
416
- static pointToSegmentDist2(a, b, x, y) {
417
- const dx = b.x - a.x, dy = b.y - a.y;
418
- const len2 = dx * dx + dy * dy || 1;
419
- let t = ((x - a.x) * dx + (y - a.y) * dy) / len2;
420
- t = Math.max(0, Math.min(1, t));
421
- const cx = a.x + dx * t, cy = a.y + dy * t;
422
- return (x - cx) * (x - cx) + (y - cy) * (y - cy);
423
- }
424
-
425
- /** Pure. Approximate squared distance from `(x,y)` to the polyline or
426
- * cubic-bezier path described by an SVG `d` attribute. Straight, polyline,
427
- * orthogonal and waypoint paths are checked exactly segment-by-segment;
428
- * cubic beziers are sampled into short line segments so the result is
429
- * accurate enough for a generous hit-test tolerance without parsing
430
- * arbitrary SVG path syntax. */
431
- static pathDistance2(d, x, y) {
432
- const tokens = (d || '').match(/[MmLlCc]|-?\d+(?:\.\d+)?/g) || [];
433
- let min = Infinity, current = { x: 0, y: 0 };
434
- let i = 0;
435
- while (i < tokens.length) {
436
- const cmd = tokens[i];
437
- if (/[MmLl]/.test(cmd)) {
438
- i++;
439
- while (i < tokens.length && !/[MmLlCc]/.test(tokens[i])) {
440
- const px = Number(tokens[i]), py = Number(tokens[i + 1]);
441
- if (Number.isFinite(px) && Number.isFinite(py)) {
442
- if (cmd === 'M' || cmd === 'L') {
443
- min = Math.min(min, EdgeGeometry.pointToSegmentDist2(current, { x: px, y: py }, x, y));
444
- current = { x: px, y: py };
445
- } else if (cmd === 'm' || cmd === 'l') {
446
- min = Math.min(min, EdgeGeometry.pointToSegmentDist2(current, { x: current.x + px, y: current.y + py }, x, y));
447
- current = { x: current.x + px, y: current.y + py };
448
- }
449
- }
450
- i += 2;
451
- }
452
- } else if (cmd === 'C' || cmd === 'c') {
453
- i++;
454
- const coords = [];
455
- while (i < tokens.length && !/[MmLlCc]/.test(tokens[i])) {
456
- coords.push(Number(tokens[i]));
457
- i++;
458
- }
459
- // Read cubic bezier triplets: c1x c1y c2x c2y x y
460
- for (let k = 0; k + 5 < coords.length; k += 6) {
461
- const p0 = current;
462
- const p1 = cmd === 'C'
463
- ? { x: coords[k], y: coords[k + 1] }
464
- : { x: current.x + coords[k], y: current.y + coords[k + 1] };
465
- const p2 = cmd === 'C'
466
- ? { x: coords[k + 2], y: coords[k + 3] }
467
- : { x: current.x + coords[k + 2], y: current.y + coords[k + 3] };
468
- const p3 = cmd === 'C'
469
- ? { x: coords[k + 4], y: coords[k + 5] }
470
- : { x: current.x + coords[k + 4], y: current.y + coords[k + 5] };
471
- let prev = p0;
472
- for (let s = 1; s <= 8; s++) {
473
- const t = s / 8;
474
- const mt = 1 - t;
475
- const pt = {
476
- x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
477
- y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
478
- };
479
- min = Math.min(min, EdgeGeometry.pointToSegmentDist2(prev, pt, x, y));
480
- prev = pt;
481
- }
482
- current = p3;
483
- }
484
- } else {
485
- i++;
486
- }
487
- }
488
- return min;
489
- }
490
-
491
- /** Pure. One edge's full geometry: `{x1,y1,x2,y2,d}` — `x1,y1`/`x2,y2` are
492
- * each anchor's final attach point (for label placement), `d` the SVG
493
- * path. Table-to-table FK links keep their own fixed left/right-column
494
- * bezier (`isTableLink`, unaffected by `routing`/side pinning). Explicit
495
- * `edge.waypoints` (see nearestSegmentInsertIndex) beat every auto
496
- * routing style, threading the line through them in order. Otherwise, an
497
- * edge picks its routing from `edge.routing` (falls back to
498
- * `defaultRouting`) and its anchor sides from
499
- * `edge.sourceSide`/`edge.targetSide` ('top'|'right'|'bottom'|'left',
500
- * default 'auto') — the single source of truth both the initial draw
501
- * (graphDiagram) and live drag frames call, so they can't drift apart. */
502
- static computeEdgePath(sourceInfo, sourcePos, sourceSize, targetInfo, targetPos, targetSize, edge, defaultRouting = 'straight', obstacles = []) {
503
- const isTableLink = sourceInfo?.shape === 'table' && targetInfo?.shape === 'table'
504
- && edge.fromCol != null && edge.toCol != null;
505
- if (isTableLink) {
506
- const y1 = NodeGeometry.tableColumnY(sourceInfo, sourcePos, sourceSize, edge.fromCol);
507
- const x1 = sourcePos.x + sourceSize.w / 2;
508
- const y2 = NodeGeometry.tableColumnY(targetInfo, targetPos, targetSize, edge.toCol);
509
- const x2 = targetPos.x - targetSize.w / 2;
510
- const mx = (x1 + x2) / 2;
511
- return { x1, y1, x2, y2, d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}` };
512
- }
513
-
514
- const sourceSide = edge.sourceSide || 'auto';
515
- const targetSide = edge.targetSide || 'auto';
516
- // 'auto' clips to the real box edge for rect/table nodes (see
517
- // edgeAnchor's own doc comment) — a fixed circular radius is only
518
- // correct for shapes actually drawn as a circle (circle/person).
519
- const sourceRectBoundary = sourceInfo?.shape === 'rect' || sourceInfo?.shape === 'table';
520
- const targetRectBoundary = targetInfo?.shape === 'rect' || targetInfo?.shape === 'table';
521
-
522
- // Explicit waypoints (dragged/inserted by the user, or supplied by the
523
- // caller) beat every auto-routing style: the line threads straight
524
- // through each one in order, and an 'auto' anchor points at the
525
- // nearest waypoint rather than at the other node.
526
- if (edge.waypoints && edge.waypoints.length) {
527
- const first = edge.waypoints[0], last = edge.waypoints[edge.waypoints.length - 1];
528
- const d1x = first.x - sourcePos.x, d1y = first.y - sourcePos.y, dist1 = Math.hypot(d1x, d1y) || 1;
529
- const d2x = targetPos.x - last.x, d2y = targetPos.y - last.y, dist2 = Math.hypot(d2x, d2y) || 1;
530
- const wa1 = EdgeGeometry.nodeAnchor(sourceInfo, sourcePos, sourceSize, sourceSide, d1x / dist1, d1y / dist1, sourceRectBoundary);
531
- const wa2 = EdgeGeometry.nodeAnchor(targetInfo, targetPos, targetSize, targetSide, -d2x / dist2, -d2y / dist2, targetRectBoundary);
532
- const points = [wa1, ...edge.waypoints, wa2];
533
- const d = points.map((pt, i) => `${i === 0 ? 'M' : 'L'}${pt.x},${pt.y}`).join(' ');
534
- return { x1: wa1.x, y1: wa1.y, x2: wa2.x, y2: wa2.y, d };
535
- }
536
-
537
- const routing = edge.routing || defaultRouting;
538
- const dx0 = targetPos.x - sourcePos.x, dy0 = targetPos.y - sourcePos.y;
539
- const dist0 = Math.hypot(dx0, dy0) || 1;
540
- const ux = dx0 / dist0, uy = dy0 / dist0;
541
- const a1 = EdgeGeometry.nodeAnchor(sourceInfo, sourcePos, sourceSize, sourceSide, ux, uy, sourceRectBoundary);
542
- const a2 = EdgeGeometry.nodeAnchor(targetInfo, targetPos, targetSize, targetSide, -ux, -uy, targetRectBoundary);
543
-
544
- switch (routing) {
545
- case 'orthogonal': return EdgeGeometry.orthogonalEdgePath(a1, a2, sourceSide, targetSide, obstacles);
546
- case 'curved': return EdgeGeometry.curvedEdgePath(a1, a2);
547
- case 'polyline': return EdgeGeometry.polylineEdgePath(a1, a2, obstacles);
548
- case 'straight':
549
- default:
550
- return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d: `M${a1.x},${a1.y} L${a2.x},${a2.y}` };
551
- }
552
- }
553
- }
1
+ import { NodeGeometry } from './NodeGeometry.js';
2
+
3
+ const SIDE_VECTORS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
4
+
5
+ /**
6
+ * EdgeGeometry — pure, stateless helpers for graph edge anchors, routing,
7
+ * waypoints, and path generation.
8
+ *
9
+ * These used to live on RenderEngine; they are collected here so diagram
10
+ * classes can compute edge geometry without going through the engine façade.
11
+ */
12
+ export class EdgeGeometry {
13
+ /** Pure. The point (and outward unit normal) where an edge attaches to a
14
+ * node's bounding box for a given `side`: 'top'/'right'/'bottom'/'left'
15
+ * pins the anchor to a fixed spot on the node — it stays there as the
16
+ * node moves, instead of sliding around the node to keep facing whatever
17
+ * it's connected to. `'auto'` (default) keeps that older sliding
18
+ * behavior, using `ux,uy` (a unit vector already pointing at the other
19
+ * endpoint) as the direction to project out to the node's edge.
20
+ *
21
+ * `rectBoundary` (true for `shape:'rect'`/`'table'`, false otherwise —
22
+ * see computeEdgePath's callers) picks WHICH boundary 'auto' projects
23
+ * onto: for a circle/person node (always drawn as a true circle, square
24
+ * bounding box or not) a fixed-radius circle is exactly its visible
25
+ * edge, but for a rect/table node that same circle radius (using the
26
+ * LARGER of w/h) massively overshoots a box that's wide but short —
27
+ * visually "the line stops in empty space well outside the rectangle"
28
+ * instead of touching it. `rectBoundary` instead finds where the ray
29
+ * from centre exits the actual w x h box (whichever of the top/bottom
30
+ * or left/right edges it reaches first). */
31
+ static edgeAnchor(pos, size, side, ux, uy, rectBoundary, shapeType = null) {
32
+ const hw = size.w / 2, hh = size.h / 2;
33
+ switch (side) {
34
+ case 'top': return { x: pos.x, y: pos.y - hh, nx: 0, ny: -1 };
35
+ case 'bottom': return { x: pos.x, y: pos.y + hh, nx: 0, ny: 1 };
36
+ case 'left': return { x: pos.x - hw, y: pos.y, nx: -1, ny: 0 };
37
+ case 'right': return { x: pos.x + hw, y: pos.y, nx: 1, ny: 0 };
38
+ default: {
39
+ if (rectBoundary) {
40
+ const tx = ux !== 0 ? hw / Math.abs(ux) : Infinity;
41
+ const ty = uy !== 0 ? hh / Math.abs(uy) : Infinity;
42
+ const t = Math.min(tx, ty);
43
+ const safeT = Number.isFinite(t) ? t : 0;
44
+ return { x: pos.x + ux * safeT, y: pos.y + uy * safeT, nx: ux, ny: uy };
45
+ }
46
+ // Diamond-specific anchoring: find intersection with actual diamond edges
47
+ if (shapeType === 'diamond') {
48
+ const t = EdgeGeometry._diamondBoundaryT(ux, uy, hw, hh);
49
+ return { x: pos.x + ux * t, y: pos.y + uy * t, nx: ux, ny: uy };
50
+ }
51
+ // Any other shape: where the ray meets its glyph's outline (an ellipse its curve, a box its box).
52
+ const outline = shapeType ? NodeGeometry.outline(shapeType, size) : null;
53
+ const t = outline ? EdgeGeometry.rayPolygonT(ux, uy, outline) : null;
54
+ if (t !== null) return { x: pos.x + ux * t, y: pos.y + uy * t, nx: ux, ny: uy };
55
+ // No outline to meet: a circle as wide as the node.
56
+ const r = Math.max(size.w, size.h) / 2;
57
+ return { x: pos.x + ux * r, y: pos.y + uy * r, nx: ux, ny: uy };
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Pure. How far along the ray from (0, 0) in direction (ux, uy) it first
64
+ * leaves a closed polygon centred there (a glyph's outline), or null when it
65
+ * meets none of its sides.
66
+ * @param {Array<[number, number]>} points
67
+ */
68
+ static rayPolygonT(ux, uy, points) {
69
+ let best = null;
70
+ for (let i = 0; i < points.length; i++) {
71
+ const [ax, ay] = points[i];
72
+ const [bx, by] = points[(i + 1) % points.length];
73
+ const ex = bx - ax, ey = by - ay;
74
+ const denom = ux * ey - uy * ex;
75
+ if (Math.abs(denom) < 1e-12) continue; // parallel to this side
76
+ const t = (ax * ey - ay * ex) / denom; // along the ray
77
+ const s = (ax * uy - ay * ux) / denom; // along the side
78
+ if (t > 1e-9 && s >= -1e-9 && s <= 1 + 1e-9 && (best === null || t < best)) best = t;
79
+ }
80
+ return best;
81
+ }
82
+
83
+ /** Pure. Given a unit direction vector (ux, uy) and diamond half-dimensions (hw, hh),
84
+ * find the distance t such that (t*ux, t*uy) intersects the diamond's perimeter.
85
+ * Diamond vertices are at (±hw, 0) and (0, ±hh), with perimeter equation |x|/hw + |y|/hh = 1.
86
+ * For a ray at (t*ux, t*uy): t*(|ux|/hw + |uy|/hh) = 1 => t = 1 / (|ux|/hw + |uy|/hh) */
87
+ static _diamondBoundaryT(ux, uy, hw, hh) {
88
+ const absUx = Math.abs(ux), absUy = Math.abs(uy);
89
+ if (absUx === 0 && absUy === 0) return 0; // Coincident nodes
90
+ const denom = (absUx / hw) + (absUy / hh);
91
+ return denom > 0 ? 1 / denom : Math.max(hw, hh);
92
+ }
93
+
94
+ /** Pure. Anchor for one end of an edge. A node that knows its own outline
95
+ * supplies `info.boundaryPoint(dx, dy) → {x, y}` (DiagramEditor templates
96
+ * do, rotation included), and the anchor is where the ray toward the other
97
+ * end — or out of the pinned side — meets it. Otherwise falls back to the
98
+ * shape-class model in edgeAnchor. */
99
+ static nodeAnchor(info, pos, size, side, ux, uy, rectBoundary) {
100
+ if (typeof info?.boundaryPoint !== 'function') {
101
+ return EdgeGeometry.edgeAnchor(pos, size, side, ux, uy, rectBoundary, info?.shape);
102
+ }
103
+ const [nx, ny] = SIDE_VECTORS[side] ?? [ux, uy];
104
+ const p = info.boundaryPoint(nx, ny);
105
+ return { x: p.x, y: p.y, nx, ny };
106
+ }
107
+
108
+ /** Pure. The side of a `size.w × size.h` node at `pos` that faces
109
+ * `toward`, comparing offsets relative to the node's proportions so a wide
110
+ * box prefers its long sides. */
111
+ static facingSide(pos, size, toward) {
112
+ const dx = toward.x - pos.x, dy = toward.y - pos.y;
113
+ if (Math.abs(dx) / (size.w || 1) >= Math.abs(dy) / (size.h || 1)) return dx >= 0 ? 'right' : 'left';
114
+ return dy >= 0 ? 'bottom' : 'top';
115
+ }
116
+
117
+ /** Pure. Unit directions in which a path arrives at each end, pointing
118
+ * into the node: `start` runs from the path's next point toward its first
119
+ * point, `end` from the previous point toward its last. Works for every
120
+ * path computeEdgePath emits (absolute M/L/C): the neighbouring coordinate
121
+ * pair is the adjacent vertex or bezier control point, both tangent to
122
+ * the end. Coincident points are skipped; {x:0,y:0} if all coincide. */
123
+ static endDirections(d) {
124
+ const nums = (d || '').match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)?.map(Number) ?? [];
125
+ const pts = [];
126
+ for (let i = 0; i + 1 < nums.length; i += 2) pts.push({ x: nums[i], y: nums[i + 1] });
127
+ const toward = (tip, others) => {
128
+ for (const p of others) {
129
+ const dx = tip.x - p.x, dy = tip.y - p.y, len = Math.hypot(dx, dy);
130
+ if (len > 1e-9) return { x: dx / len, y: dy / len };
131
+ }
132
+ return { x: 0, y: 0 };
133
+ };
134
+ if (!pts.length) return { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } };
135
+ return {
136
+ start: toward(pts[0], pts.slice(1)),
137
+ end: toward(pts[pts.length - 1], pts.slice(0, -1).reverse()),
138
+ };
139
+ }
140
+
141
+ /** Pure. 'h' when `side` pins the anchor to a vertical edge of the node's
142
+ * box (left/right — the line touches it moving horizontally), 'v' for
143
+ * top/bottom, null for 'auto' (no fixed orientation to route around). */
144
+ static edgeSideOrientation(side) {
145
+ if (side === 'left' || side === 'right') return 'h';
146
+ if (side === 'top' || side === 'bottom') return 'v';
147
+ return null;
148
+ }
149
+
150
+ /** Pure. Cubic-bezier path between two pinned/auto anchors, leaving and
151
+ * arriving along each anchor's own normal (`nx,ny`) so a pinned side
152
+ * produces a visibly perpendicular exit/entry instead of a straight
153
+ * dog-leg. The generalized shape of the table-link curve in
154
+ * computeEdgePath, usable for any node pair. */
155
+ static curvedEdgePath(a1, a2) {
156
+ const dist = Math.hypot(a2.x - a1.x, a2.y - a1.y) || 1;
157
+ const k = Math.min(dist * 0.5, 80);
158
+ const c1x = a1.x + a1.nx * k, c1y = a1.y + a1.ny * k;
159
+ const c2x = a2.x + a2.nx * k, c2y = a2.y + a2.ny * k;
160
+ return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d: `M${a1.x},${a1.y} C${c1x},${c1y} ${c2x},${c2y} ${a2.x},${a2.y}` };
161
+ }
162
+
163
+ /** Pure. Right-angle (Manhattan) path between two anchors. When a side is
164
+ * pinned, the segment touching that anchor is forced to the matching
165
+ * orientation (horizontal for left/right, vertical for top/bottom); with
166
+ * both anchors 'auto' it just bends along whichever axis has the bigger
167
+ * gap. Mixed orientations (e.g. source pinned right, target pinned top)
168
+ * get a single-corner elbow instead of the usual midpoint jog, since a
169
+ * midpoint jog can't satisfy two different orientations at once.
170
+ *
171
+ * With `obstacles` (array of {x,y,r} nodes), routes around blocking obstacles
172
+ * using axis-aligned detour lanes. No avoidance for mixed-orientation elbows;
173
+ * those are rare and have degenerate geometry. */
174
+ static orthogonalEdgePath(a1, a2, sourceSide, targetSide, obstacles = []) {
175
+ const so = EdgeGeometry.edgeSideOrientation(sourceSide);
176
+ const to = EdgeGeometry.edgeSideOrientation(targetSide);
177
+ let d;
178
+ if (so && to && so !== to) {
179
+ const corner = so === 'h' ? { x: a2.x, y: a1.y } : { x: a1.x, y: a2.y };
180
+ d = `M${a1.x},${a1.y} L${corner.x},${corner.y} L${a2.x},${a2.y}`;
181
+ } else {
182
+ const horizontalFirst = so ? so === 'h' : (to ? to === 'h' : Math.abs(a2.x - a1.x) >= Math.abs(a2.y - a1.y));
183
+ const blocking = EdgeGeometry.findBlockingObstacles(a1, a2, obstacles);
184
+
185
+ if (blocking.length === 0) {
186
+ // No obstacles — use the plain midpoint jog
187
+ if (horizontalFirst) {
188
+ const midX = (a1.x + a2.x) / 2;
189
+ d = `M${a1.x},${a1.y} L${midX},${a1.y} L${midX},${a2.y} L${a2.x},${a2.y}`;
190
+ } else {
191
+ const midY = (a1.y + a2.y) / 2;
192
+ d = `M${a1.x},${a1.y} L${a1.x},${midY} L${a2.x},${midY} L${a2.x},${a2.y}`;
193
+ }
194
+ } else {
195
+ // Route around the obstacle cluster with a detour lane
196
+ const bounds = EdgeGeometry.obstacleClusterBounds(blocking);
197
+ if (horizontalFirst) {
198
+ const naturalY = (a1.y + a2.y) / 2;
199
+ const routeY = naturalY < bounds.minY ? bounds.minY - 12 : bounds.maxY + 12;
200
+ d = `M${a1.x},${a1.y} L${a1.x},${routeY} L${a2.x},${routeY} L${a2.x},${a2.y}`;
201
+ } else {
202
+ const naturalX = (a1.x + a2.x) / 2;
203
+ const routeX = naturalX < bounds.minX ? bounds.minX - 12 : bounds.maxX + 12;
204
+ d = `M${a1.x},${a1.y} L${routeX},${a1.y} L${routeX},${a2.y} L${a2.x},${a2.y}`;
205
+ }
206
+ }
207
+ }
208
+ return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d };
209
+ }
210
+
211
+ /** Pure. True if the segment a1->a2 passes within `circle.r` of `circle`'s
212
+ * centre (see pointToSegmentDist2 for the underlying clamped-distance math). */
213
+ static segmentIntersectsCircle(a1, a2, circle) {
214
+ return EdgeGeometry.pointToSegmentDist2(a1, a2, circle.x, circle.y) < circle.r * circle.r;
215
+ }
216
+
217
+ /** Pure. Find all obstacles that block the direct line from a1 to a2.
218
+ * Returns array of blocking obstacles, sorted by their position along the line. */
219
+ static findBlockingObstacles(a1, a2, obstacles = []) {
220
+ return obstacles
221
+ .filter(o => EdgeGeometry.segmentIntersectsCircle(a1, a2, o))
222
+ .sort((o1, o2) => {
223
+ const dx = a2.x - a1.x, dy = a2.y - a1.y;
224
+ const len2 = dx * dx + dy * dy;
225
+ const t1 = ((o1.x - a1.x) * dx + (o1.y - a1.y) * dy) / len2;
226
+ const t2 = ((o2.x - a1.x) * dx + (o2.y - a1.y) * dy) / len2;
227
+ return t1 - t2;
228
+ });
229
+ }
230
+
231
+ /** Pure. Compute bounding box around a cluster of obstacles with clearance margin. */
232
+ static obstacleClusterBounds(obstacles, margin = 12) {
233
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
234
+ for (const o of obstacles) {
235
+ minX = Math.min(minX, o.x - o.r - margin);
236
+ minY = Math.min(minY, o.y - o.r - margin);
237
+ maxX = Math.max(maxX, o.x + o.r + margin);
238
+ maxY = Math.max(maxY, o.y + o.r + margin);
239
+ }
240
+ return { minX, minY, maxX, maxY };
241
+ }
242
+
243
+ /** Pure. Route around a cluster of obstacles by computing two waypoints (above/below or left/right).
244
+ * Returns array of waypoints to thread between source and target anchors. */
245
+ static routeAroundCluster(a1, a2, obstacles) {
246
+ const bounds = EdgeGeometry.obstacleClusterBounds(obstacles);
247
+ const dx = a2.x - a1.x, dy = a2.y - a1.y;
248
+ const isHorizontal = Math.abs(dx) >= Math.abs(dy);
249
+
250
+ // Choose routing direction: if more horizontal, route above/below; else left/right
251
+ if (isHorizontal) {
252
+ // Route vertically around the cluster
253
+ const side = a1.y < bounds.minY ? -1 : 1; // above if source is above, else below
254
+ const routeY = side > 0 ? bounds.maxY : bounds.minY;
255
+ const midX = (bounds.minX + bounds.maxX) / 2;
256
+ return [
257
+ { x: midX, y: a1.y }, // move horizontally to cluster's horizontal center
258
+ { x: midX, y: routeY }, // move vertically around cluster
259
+ ];
260
+ } else {
261
+ // Route horizontally around the cluster
262
+ const side = a1.x < bounds.minX ? -1 : 1; // left if source is left, else right
263
+ const routeX = side > 0 ? bounds.maxX : bounds.minX;
264
+ const midY = (bounds.minY + bounds.maxY) / 2;
265
+ return [
266
+ { x: a1.x, y: midY }, // move vertically to cluster's vertical center
267
+ { x: routeX, y: midY }, // move horizontally around cluster
268
+ ];
269
+ }
270
+ }
271
+
272
+ /** Pure. Straight line between two anchors, unless `obstacles` (other
273
+ * nodes as {x,y,r}, source/target already excluded by the caller) puts
274
+ * one in the way. Routes around single obstacles with a perpendicular
275
+ * waypoint; routes around clusters by threading around the bounding box.
276
+ * Degrades to a plain straight line on an uncrowded graph.
277
+ *
278
+ * Note: This is 'polyline' routing only. 'curved' and 'orthogonal' modes
279
+ * currently do not have obstacle avoidance — they route directly regardless
280
+ * of obstacles. A caller needing avoidance on those modes should either:
281
+ * 1. Switch to 'polyline' routing, or
282
+ * 2. Manually add waypoints (edge.waypoints) to the edge data. */
283
+ static polylineEdgePath(a1, a2, obstacles = []) {
284
+ const blocking = EdgeGeometry.findBlockingObstacles(a1, a2, obstacles);
285
+ if (blocking.length === 0) {
286
+ return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d: `M${a1.x},${a1.y} L${a2.x},${a2.y}` };
287
+ }
288
+
289
+ let waypoints;
290
+ if (blocking.length === 1) {
291
+ // Single obstacle: use perpendicular offset (original behavior)
292
+ const o = blocking[0];
293
+ const dx = a2.x - a1.x, dy = a2.y - a1.y;
294
+ const len = Math.hypot(dx, dy) || 1;
295
+ const px = -dy / len, py = dx / len;
296
+ const len2 = len * len;
297
+ const t = ((o.x - a1.x) * dx + (o.y - a1.y) * dy) / len2;
298
+ const footX = a1.x + dx * t, footY = a1.y + dy * t;
299
+ const cross = dx * (o.y - a1.y) - dy * (o.x - a1.x);
300
+ const side = cross >= 0 ? -1 : 1;
301
+ const clearance = o.r + 12;
302
+ waypoints = [{ x: footX + px * clearance * side, y: footY + py * clearance * side }];
303
+ } else {
304
+ // Multiple obstacles: route around the cluster
305
+ waypoints = EdgeGeometry.routeAroundCluster(a1, a2, blocking);
306
+ }
307
+
308
+ const points = [a1, ...waypoints, a2];
309
+ const d = points.map((pt, i) => `${i === 0 ? 'M' : 'L'}${pt.x},${pt.y}`).join(' ');
310
+ return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d };
311
+ }
312
+
313
+ /** Pure. Given `points` (an ordered polyline — anchor, any existing
314
+ * waypoints, anchor), finds which SEGMENT a click at `(x,y)` landed
315
+ * closest to and returns that segment's starting index. Since
316
+ * `points[0]` is the source anchor (not a stored waypoint), segment `i`
317
+ * is exactly where a new `edge.waypoints[i]` belongs (`Array.splice(i, 0,
318
+ * point)`) — see the double-click-to-add-a-bend handler in
319
+ * RenderEngine's drag listeners. */
320
+ static nearestSegmentInsertIndex(points, x, y) {
321
+ let bestIdx = 0, bestDist2 = Infinity;
322
+ for (let i = 0; i < points.length - 1; i++) {
323
+ const dist2 = EdgeGeometry.pointToSegmentDist2(points[i], points[i + 1], x, y);
324
+ if (dist2 < bestDist2) { bestDist2 = dist2; bestIdx = i; }
325
+ }
326
+ return bestIdx;
327
+ }
328
+
329
+ /** Pure. Detect if two line segments intersect. Returns intersection point if they do, null otherwise.
330
+ * Used for edge-crossing visualization. */
331
+ static lineSegmentIntersection(p1, p2, p3, p4) {
332
+ const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y;
333
+ const x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
334
+
335
+ const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
336
+ if (Math.abs(denom) < 1e-10) return null; // parallel or coincident
337
+
338
+ const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
339
+ const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
340
+
341
+ if (t > 0 && t < 1 && u > 0 && u < 1) {
342
+ return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) };
343
+ }
344
+ return null;
345
+ }
346
+
347
+ /** Pure. Find all crossing points between a given edge path and an array of other edge paths.
348
+ * Returns array of {x, y, angle} crossing points. */
349
+ static findEdgeCrossings(edgePath, otherEdgePaths) {
350
+ const crossings = [];
351
+ const points = EdgeGeometry.pathToPoints(edgePath);
352
+
353
+ for (const otherPath of otherEdgePaths) {
354
+ const otherPoints = EdgeGeometry.pathToPoints(otherPath);
355
+ for (let i = 0; i < points.length - 1; i++) {
356
+ for (let j = 0; j < otherPoints.length - 1; j++) {
357
+ const intersection = EdgeGeometry.lineSegmentIntersection(
358
+ points[i], points[i + 1],
359
+ otherPoints[j], otherPoints[j + 1]
360
+ );
361
+ if (intersection) {
362
+ const angle = Math.atan2(
363
+ points[i + 1].y - points[i].y,
364
+ points[i + 1].x - points[i].x
365
+ );
366
+ crossings.push({ ...intersection, angle });
367
+ }
368
+ }
369
+ }
370
+ }
371
+ return crossings;
372
+ }
373
+
374
+ /** Pure. Convert SVG path string to array of points (samples bezier curves).
375
+ * Used for edge-crossing detection. */
376
+ static pathToPoints(pathStr) {
377
+ const points = [];
378
+ const tokens = pathStr.match(/[MmLlCc]|[+-]?[\d.]+/g) || [];
379
+ let current = { x: 0, y: 0 };
380
+ let i = 0;
381
+
382
+ while (i < tokens.length) {
383
+ const cmd = tokens[i];
384
+ if (cmd === 'M' || cmd === 'm') {
385
+ i++;
386
+ const x = Number(tokens[i++]), y = Number(tokens[i++]);
387
+ current = cmd === 'M' ? { x, y } : { x: current.x + x, y: current.y + y };
388
+ points.push({ ...current });
389
+ } else if (cmd === 'L' || cmd === 'l') {
390
+ i++;
391
+ const x = Number(tokens[i++]), y = Number(tokens[i++]);
392
+ current = cmd === 'L' ? { x, y } : { x: current.x + x, y: current.y + y };
393
+ points.push({ ...current });
394
+ } else if (cmd === 'C' || cmd === 'c') {
395
+ i++;
396
+ const coords = [];
397
+ while (i < tokens.length && !/[MmLlCc]/.test(tokens[i])) {
398
+ coords.push(Number(tokens[i++]));
399
+ }
400
+ const p0 = current;
401
+ const p1 = cmd === 'C' ? { x: coords[0], y: coords[1] } : { x: current.x + coords[0], y: current.y + coords[1] };
402
+ const p2 = cmd === 'C' ? { x: coords[2], y: coords[3] } : { x: current.x + coords[2], y: current.y + coords[3] };
403
+ const p3 = cmd === 'C' ? { x: coords[4], y: coords[5] } : { x: current.x + coords[4], y: current.y + coords[5] };
404
+
405
+ for (let s = 1; s <= 8; s++) {
406
+ const t = s / 8;
407
+ const mt = 1 - t;
408
+ const pt = {
409
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
410
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
411
+ };
412
+ points.push(pt);
413
+ }
414
+ current = p3;
415
+ } else {
416
+ i++;
417
+ }
418
+ }
419
+ return points;
420
+ }
421
+
422
+ /** Pure. Returns a NEW `edge.waypoints` array with one more point added
423
+ * at `point`, landing in the segment nearest it along the current route
424
+ * sourcePos -> ...edge.waypoints -> targetPos (see nearestSegmentInsertIndex).
425
+ * Used by the edge context menu's "Add Waypoint Here" — hand-routing used
426
+ * to be reachable by double-clicking the edge line itself, but that
427
+ * gesture now opens `onEdgeDoubleClick`'s properties dialog instead, so
428
+ * adding a bend point moved to the context menu. */
429
+ static edgeWaypointsWithInsertion(edge, sourcePos, targetPos, point) {
430
+ const points = [sourcePos, ...(edge.waypoints || []), targetPos];
431
+ const insertIdx = EdgeGeometry.nearestSegmentInsertIndex(points, point.x, point.y);
432
+ const waypoints = edge.waypoints ? [...edge.waypoints] : [];
433
+ waypoints.splice(insertIdx, 0, { x: point.x, y: point.y });
434
+ return waypoints;
435
+ }
436
+
437
+ /** Pure. Squared distance from `(x,y)` to the closest point on segment
438
+ * a->b (clamped to the segment, not the infinite line) — shared by
439
+ * segmentIntersectsCircle's threshold test and nearestSegmentInsertIndex's
440
+ * plain distance comparison. */
441
+ static pointToSegmentDist2(a, b, x, y) {
442
+ const dx = b.x - a.x, dy = b.y - a.y;
443
+ const len2 = dx * dx + dy * dy || 1;
444
+ let t = ((x - a.x) * dx + (y - a.y) * dy) / len2;
445
+ t = Math.max(0, Math.min(1, t));
446
+ const cx = a.x + dx * t, cy = a.y + dy * t;
447
+ return (x - cx) * (x - cx) + (y - cy) * (y - cy);
448
+ }
449
+
450
+ /** Pure. Approximate squared distance from `(x,y)` to the polyline or
451
+ * cubic-bezier path described by an SVG `d` attribute. Straight, polyline,
452
+ * orthogonal and waypoint paths are checked exactly segment-by-segment;
453
+ * cubic beziers are sampled into short line segments so the result is
454
+ * accurate enough for a generous hit-test tolerance without parsing
455
+ * arbitrary SVG path syntax. */
456
+ static pathDistance2(d, x, y) {
457
+ const tokens = (d || '').match(/[MmLlCc]|-?\d+(?:\.\d+)?/g) || [];
458
+ let min = Infinity, current = { x: 0, y: 0 };
459
+ let i = 0;
460
+ while (i < tokens.length) {
461
+ const cmd = tokens[i];
462
+ if (/[MmLl]/.test(cmd)) {
463
+ i++;
464
+ while (i < tokens.length && !/[MmLlCc]/.test(tokens[i])) {
465
+ const px = Number(tokens[i]), py = Number(tokens[i + 1]);
466
+ if (Number.isFinite(px) && Number.isFinite(py)) {
467
+ if (cmd === 'M' || cmd === 'L') {
468
+ min = Math.min(min, EdgeGeometry.pointToSegmentDist2(current, { x: px, y: py }, x, y));
469
+ current = { x: px, y: py };
470
+ } else if (cmd === 'm' || cmd === 'l') {
471
+ min = Math.min(min, EdgeGeometry.pointToSegmentDist2(current, { x: current.x + px, y: current.y + py }, x, y));
472
+ current = { x: current.x + px, y: current.y + py };
473
+ }
474
+ }
475
+ i += 2;
476
+ }
477
+ } else if (cmd === 'C' || cmd === 'c') {
478
+ i++;
479
+ const coords = [];
480
+ while (i < tokens.length && !/[MmLlCc]/.test(tokens[i])) {
481
+ coords.push(Number(tokens[i]));
482
+ i++;
483
+ }
484
+ // Read cubic bezier triplets: c1x c1y c2x c2y x y
485
+ for (let k = 0; k + 5 < coords.length; k += 6) {
486
+ const p0 = current;
487
+ const p1 = cmd === 'C'
488
+ ? { x: coords[k], y: coords[k + 1] }
489
+ : { x: current.x + coords[k], y: current.y + coords[k + 1] };
490
+ const p2 = cmd === 'C'
491
+ ? { x: coords[k + 2], y: coords[k + 3] }
492
+ : { x: current.x + coords[k + 2], y: current.y + coords[k + 3] };
493
+ const p3 = cmd === 'C'
494
+ ? { x: coords[k + 4], y: coords[k + 5] }
495
+ : { x: current.x + coords[k + 4], y: current.y + coords[k + 5] };
496
+ let prev = p0;
497
+ for (let s = 1; s <= 8; s++) {
498
+ const t = s / 8;
499
+ const mt = 1 - t;
500
+ const pt = {
501
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
502
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
503
+ };
504
+ min = Math.min(min, EdgeGeometry.pointToSegmentDist2(prev, pt, x, y));
505
+ prev = pt;
506
+ }
507
+ current = p3;
508
+ }
509
+ } else {
510
+ i++;
511
+ }
512
+ }
513
+ return min;
514
+ }
515
+
516
+ /** Pure. One edge's full geometry: `{x1,y1,x2,y2,d}` — `x1,y1`/`x2,y2` are
517
+ * each anchor's final attach point (for label placement), `d` the SVG
518
+ * path. Table-to-table FK links keep their own fixed left/right-column
519
+ * bezier (`isTableLink`, unaffected by `routing`/side pinning). Explicit
520
+ * `edge.waypoints` (see nearestSegmentInsertIndex) beat every auto
521
+ * routing style, threading the line through them in order. Otherwise, an
522
+ * edge picks its routing from `edge.routing` (falls back to
523
+ * `defaultRouting`) and its anchor sides from
524
+ * `edge.sourceSide`/`edge.targetSide` ('top'|'right'|'bottom'|'left',
525
+ * default 'auto') — the single source of truth both the initial draw
526
+ * (graphDiagram) and live drag frames call, so they can't drift apart. */
527
+ static computeEdgePath(sourceInfo, sourcePos, sourceSize, targetInfo, targetPos, targetSize, edge, defaultRouting = 'straight', obstacles = []) {
528
+ const isTableLink = sourceInfo?.shape === 'table' && targetInfo?.shape === 'table'
529
+ && edge.fromCol != null && edge.toCol != null;
530
+ if (isTableLink) {
531
+ const y1 = NodeGeometry.tableColumnY(sourceInfo, sourcePos, sourceSize, edge.fromCol);
532
+ const x1 = sourcePos.x + sourceSize.w / 2;
533
+ const y2 = NodeGeometry.tableColumnY(targetInfo, targetPos, targetSize, edge.toCol);
534
+ const x2 = targetPos.x - targetSize.w / 2;
535
+ const mx = (x1 + x2) / 2;
536
+ return { x1, y1, x2, y2, d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}` };
537
+ }
538
+
539
+ const sourceSide = edge.sourceSide || 'auto';
540
+ const targetSide = edge.targetSide || 'auto';
541
+ // 'auto' clips to the real box edge for rect/table nodes (see
542
+ // edgeAnchor's own doc comment) — a fixed circular radius is only
543
+ // correct for shapes actually drawn as a circle (circle/person).
544
+ const sourceRectBoundary = sourceInfo?.shape === 'rect' || sourceInfo?.shape === 'table';
545
+ const targetRectBoundary = targetInfo?.shape === 'rect' || targetInfo?.shape === 'table';
546
+
547
+ // Explicit waypoints (dragged/inserted by the user, or supplied by the
548
+ // caller) beat every auto-routing style: the line threads straight
549
+ // through each one in order, and an 'auto' anchor points at the
550
+ // nearest waypoint rather than at the other node.
551
+ if (edge.waypoints && edge.waypoints.length) {
552
+ const first = edge.waypoints[0], last = edge.waypoints[edge.waypoints.length - 1];
553
+ const d1x = first.x - sourcePos.x, d1y = first.y - sourcePos.y, dist1 = Math.hypot(d1x, d1y) || 1;
554
+ const d2x = targetPos.x - last.x, d2y = targetPos.y - last.y, dist2 = Math.hypot(d2x, d2y) || 1;
555
+ const wa1 = EdgeGeometry.nodeAnchor(sourceInfo, sourcePos, sourceSize, sourceSide, d1x / dist1, d1y / dist1, sourceRectBoundary);
556
+ const wa2 = EdgeGeometry.nodeAnchor(targetInfo, targetPos, targetSize, targetSide, -d2x / dist2, -d2y / dist2, targetRectBoundary);
557
+ const points = [wa1, ...edge.waypoints, wa2];
558
+ const d = points.map((pt, i) => `${i === 0 ? 'M' : 'L'}${pt.x},${pt.y}`).join(' ');
559
+ return { x1: wa1.x, y1: wa1.y, x2: wa2.x, y2: wa2.y, d };
560
+ }
561
+
562
+ const routing = edge.routing || defaultRouting;
563
+ const dx0 = targetPos.x - sourcePos.x, dy0 = targetPos.y - sourcePos.y;
564
+ const dist0 = Math.hypot(dx0, dy0) || 1;
565
+ const ux = dx0 / dist0, uy = dy0 / dist0;
566
+ const a1 = EdgeGeometry.nodeAnchor(sourceInfo, sourcePos, sourceSize, sourceSide, ux, uy, sourceRectBoundary);
567
+ const a2 = EdgeGeometry.nodeAnchor(targetInfo, targetPos, targetSize, targetSide, -ux, -uy, targetRectBoundary);
568
+
569
+ switch (routing) {
570
+ case 'orthogonal': return EdgeGeometry.orthogonalEdgePath(a1, a2, sourceSide, targetSide, obstacles);
571
+ case 'curved': return EdgeGeometry.curvedEdgePath(a1, a2);
572
+ case 'polyline': return EdgeGeometry.polylineEdgePath(a1, a2, obstacles);
573
+ case 'straight':
574
+ default:
575
+ return { x1: a1.x, y1: a1.y, x2: a2.x, y2: a2.y, d: `M${a1.x},${a1.y} L${a2.x},${a2.y}` };
576
+ }
577
+ }
578
+ }