flowsvg 0.1.0 → 0.2.0

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 (98) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/PATTERNS.md +71 -4
  3. package/README.md +148 -438
  4. package/README.zh-CN.md +148 -438
  5. package/dist/adapters/react.d.ts +55 -0
  6. package/dist/adapters/react.js +138 -0
  7. package/dist/adapters/vue.d.ts +158 -0
  8. package/dist/adapters/vue.js +177 -0
  9. package/dist/adapters/web-component.d.ts +111 -0
  10. package/dist/adapters/web-component.js +425 -0
  11. package/dist/{player-controls.d.ts → browser/controls.d.ts} +7 -3
  12. package/dist/{player-controls.js → browser/controls.js} +23 -31
  13. package/dist/browser/export-controls.d.ts +32 -0
  14. package/dist/browser/export-controls.js +151 -0
  15. package/dist/browser/player.d.ts +77 -0
  16. package/dist/browser/player.js +412 -0
  17. package/dist/browser/select-control.d.ts +33 -0
  18. package/dist/browser/select-control.js +171 -0
  19. package/dist/browser.d.ts +5 -69
  20. package/dist/browser.js +3 -353
  21. package/dist/core/color.d.ts +3 -0
  22. package/dist/core/color.js +18 -0
  23. package/dist/core/diagram.d.ts +40 -0
  24. package/dist/core/diagram.js +481 -0
  25. package/dist/core/edge-geometry.d.ts +45 -0
  26. package/dist/core/edge-geometry.js +609 -0
  27. package/dist/{core.d.ts → core/model.d.ts} +37 -61
  28. package/dist/core/model.js +1 -0
  29. package/dist/core/node-content-measurement.d.ts +34 -0
  30. package/dist/core/node-content-measurement.js +66 -0
  31. package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
  32. package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
  33. package/dist/{node-content.d.ts → core/node-content.d.ts} +18 -1
  34. package/dist/{node-content.js → core/node-content.js} +30 -6
  35. package/dist/core/svg-number.d.ts +5 -0
  36. package/dist/core/svg-number.js +7 -0
  37. package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
  38. package/dist/core/theme.js +17 -0
  39. package/dist/{timeline.d.ts → core/timeline.d.ts} +11 -5
  40. package/dist/{timeline.js → core/timeline.js} +30 -22
  41. package/dist/core/validation.d.ts +11 -0
  42. package/dist/core/validation.js +399 -0
  43. package/dist/export/browser.d.ts +9 -0
  44. package/dist/export/browser.js +172 -0
  45. package/dist/export/captions.d.ts +18 -0
  46. package/dist/export/captions.js +44 -0
  47. package/dist/export/plan.d.ts +25 -0
  48. package/dist/export/plan.js +94 -0
  49. package/dist/export/types.d.ts +64 -0
  50. package/dist/export/types.js +9 -0
  51. package/dist/export/worker-source.d.ts +1 -0
  52. package/dist/export/worker-source.js +1 -0
  53. package/dist/export.d.ts +3 -0
  54. package/dist/export.js +2 -0
  55. package/dist/index.d.ts +7 -1
  56. package/dist/index.js +3 -1
  57. package/dist/node/cli.d.ts +2 -0
  58. package/dist/node/cli.js +72 -0
  59. package/dist/node/gif.d.ts +16 -0
  60. package/dist/node/gif.js +184 -0
  61. package/dist/node/html.d.ts +19 -0
  62. package/dist/node/html.js +61 -0
  63. package/dist/node/output.d.ts +2 -0
  64. package/dist/node/output.js +22 -0
  65. package/dist/node.d.ts +6 -0
  66. package/dist/node.js +3 -0
  67. package/dist/portable/LICENSE.esbuild.txt +21 -0
  68. package/dist/portable/LICENSE.flowsvg.txt +21 -0
  69. package/dist/portable/LICENSE.gifenc.txt +21 -0
  70. package/dist/portable/export.js +5 -0
  71. package/dist/portable/manifest.json +36 -0
  72. package/dist/portable/player.js +2 -0
  73. package/dist/portable/styles.css +227 -0
  74. package/dist/react.d.ts +3 -48
  75. package/dist/react.js +2 -126
  76. package/dist/styles.css +194 -14
  77. package/dist/vue.d.ts +3 -132
  78. package/dist/vue.js +2 -161
  79. package/dist/web-component.d.ts +3 -103
  80. package/dist/web-component.js +2 -396
  81. package/docs/images/entity-graph.png +0 -0
  82. package/docs/images/facts-timeline.png +0 -0
  83. package/docs/images/facts-timeline.zh-CN.png +0 -0
  84. package/docs/images/flowsvg-wordmark.gif +0 -0
  85. package/docs/images/node-content.png +0 -0
  86. package/docs/images/numbered-list.png +0 -0
  87. package/docs/images/numbered-list.zh-CN.png +0 -0
  88. package/docs/images/quick-start.png +0 -0
  89. package/docs/images/record-tags.png +0 -0
  90. package/package.json +38 -6
  91. package/dist/core.js +0 -1149
  92. package/dist/theme.js +0 -6
  93. /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
  94. /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
  95. /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
  96. /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
  97. /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
  98. /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Platform-independent edge geometry. This module owns routing, sampled path
3
+ * distance, label anchors and line hit testing; diagram evaluation owns the
4
+ * surrounding frame state and consumes this internal interface.
5
+ */
6
+ import { formatNumber } from "./svg-number.js";
7
+ function straightPoints(from, to) {
8
+ const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
9
+ const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
10
+ const start = boundaryPoint(from, toCenter);
11
+ const end = boundaryPoint(to, fromCenter);
12
+ return [start, end];
13
+ }
14
+ const ROUTING_GAP = 8;
15
+ const PORT_SPACING = 24;
16
+ const MIN_ARROW_APPROACH = 24;
17
+ const ORTHOGONAL_BEND_RADIUS = 12;
18
+ const BEND_SAMPLE_COUNT = 16;
19
+ const BEND_CLEARANCE_EPSILON = 0.002;
20
+ const BEND_RADIUS_SEARCH_STEPS = 16;
21
+ const BEND_RADIUS_PRECISION = 1000;
22
+ const CYLINDER_CAP_RADIUS = 14;
23
+ /** SVG marker size in stroke-width units; routing uses the same footprint as serialization. */
24
+ export const ARROW_MARKER_SIZE = 7;
25
+ const FOCUS_PADDING = 24;
26
+ const HIT_TOLERANCE = 6;
27
+ export function emptyPathGeometry() {
28
+ return createPathGeometry([]);
29
+ }
30
+ function createPathGeometry(points, serializedPath, terminalControl, paintedBottom) {
31
+ const compact = simplifyPoints(points);
32
+ const path = serializedPath ?? pointsToPath(compact);
33
+ const segments = compact.slice(1).map((end, index) => ({ start: compact[index], end, length: Math.hypot(end.x - compact[index].x, end.y - compact[index].y) }));
34
+ const length = segments.reduce((total, segment) => total + segment.length, 0);
35
+ const xs = compact.map((point) => point.x);
36
+ const ys = compact.map((point) => point.y);
37
+ const bounds = xs.length === 0
38
+ ? { left: 0, top: 0, right: 0, bottom: 0 }
39
+ : { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
40
+ return {
41
+ path,
42
+ points: compact,
43
+ length: length || 1,
44
+ terminalDirection: terminalDirection(compact, terminalControl),
45
+ paintedBottom: paintedBottom ?? bounds.bottom,
46
+ bounds,
47
+ pointAt(progress) {
48
+ if (compact.length === 0)
49
+ return { x: 0, y: 0 };
50
+ if (compact.length === 1 || length === 0)
51
+ return compact[0];
52
+ const distance = Math.min(1, Math.max(0, progress)) * length;
53
+ let traversed = 0;
54
+ for (const segment of segments) {
55
+ if (distance <= traversed + segment.length) {
56
+ const ratio = segment.length === 0 ? 0 : (distance - traversed) / segment.length;
57
+ return {
58
+ x: segment.start.x + (segment.end.x - segment.start.x) * ratio,
59
+ y: segment.start.y + (segment.end.y - segment.start.y) * ratio
60
+ };
61
+ }
62
+ traversed += segment.length;
63
+ }
64
+ return compact.at(-1);
65
+ }
66
+ };
67
+ }
68
+ /** Use the exact final cubic control, or the final straight run, for the SVG marker orientation. */
69
+ function terminalDirection(points, control) {
70
+ const end = points.at(-1);
71
+ const previous = control ?? points.at(-2);
72
+ if (!end || !previous)
73
+ return { x: 1, y: 0 };
74
+ const dx = end.x - previous.x;
75
+ const dy = end.y - previous.y;
76
+ const length = Math.hypot(dx, dy);
77
+ return length > 0 ? { x: dx / length, y: dy / length } : { x: 1, y: 0 };
78
+ }
79
+ /**
80
+ * Trim orthogonal corners into quadratic bends. Adjacent trims cannot overlap,
81
+ * and the final run retains the marker approach. SVG and distance consumers
82
+ * share the same controls; conservative hull checks protect stroked node bounds.
83
+ */
84
+ function createRoundedGeometry(points, obstacles, strokeWidth) {
85
+ const route = simplifyPoints(points);
86
+ if (route.length < 3)
87
+ return createPathGeometry(route);
88
+ const samples = [route[0]];
89
+ const commands = [`M ${formatNumber(route[0].x)} ${formatNumber(route[0].y)}`];
90
+ const approach = Math.max(MIN_ARROW_APPROACH, strokeWidth * ARROW_MARKER_SIZE + ROUTING_GAP);
91
+ for (let index = 1; index < route.length - 1; index += 1) {
92
+ const previous = route[index - 1];
93
+ const corner = route[index];
94
+ const next = route[index + 1];
95
+ const incoming = Math.hypot(corner.x - previous.x, corner.y - previous.y);
96
+ const outgoing = Math.hypot(next.x - corner.x, next.y - corner.y);
97
+ const turns = (corner.x - previous.x) * (next.x - corner.x) + (corner.y - previous.y) * (next.y - corner.y) === 0;
98
+ const maximum = turns ? Math.max(0, Math.min(ORTHOGONAL_BEND_RADIUS, incoming / 2, outgoing / 2, index === route.length - 2 ? outgoing - Math.min(approach, outgoing) : ORTHOGONAL_BEND_RADIUS)) : 0;
99
+ const radius = clearBendRadius(corner, previous, next, maximum, obstacles, strokeWidth / 2);
100
+ const entry = moveToward(corner, previous, radius);
101
+ const exit = moveToward(corner, next, radius);
102
+ commands.push(`L ${formatNumber(entry.x)} ${formatNumber(entry.y)}`);
103
+ samples.push(entry);
104
+ if (radius === 0)
105
+ continue;
106
+ commands.push(`Q ${formatNumber(corner.x)} ${formatNumber(corner.y)} ${formatNumber(exit.x)} ${formatNumber(exit.y)}`);
107
+ for (let sample = 1; sample <= BEND_SAMPLE_COUNT; sample += 1) {
108
+ const progress = sample / BEND_SAMPLE_COUNT;
109
+ const inverse = 1 - progress;
110
+ samples.push({ x: inverse ** 2 * entry.x + 2 * inverse * progress * corner.x + progress ** 2 * exit.x,
111
+ y: inverse ** 2 * entry.y + 2 * inverse * progress * corner.y + progress ** 2 * exit.y });
112
+ }
113
+ }
114
+ const end = route.at(-1);
115
+ commands.push(`L ${formatNumber(end.x)} ${formatNumber(end.y)}`);
116
+ samples.push(end);
117
+ return createPathGeometry(samples, commands.join(" "));
118
+ }
119
+ function moveToward(origin, target, distance) {
120
+ const length = Math.hypot(target.x - origin.x, target.y - origin.y);
121
+ return { x: origin.x + (target.x - origin.x) / length * distance,
122
+ y: origin.y + (target.y - origin.y) / length * distance };
123
+ }
124
+ /** Shrinking the control hull cannot create a new collision. Retain a rounding margin for SVG serialization. */
125
+ function clearBendRadius(corner, previous, next, maximum, obstacles, strokeRadius) {
126
+ const clear = (radius) => {
127
+ const entry = moveToward(corner, previous, radius);
128
+ const exit = moveToward(corner, next, radius);
129
+ const margin = strokeRadius + BEND_CLEARANCE_EPSILON;
130
+ return !obstacles.some((bounds) => Math.min(entry.x, corner.x, exit.x) < bounds.right + margin
131
+ && Math.max(entry.x, corner.x, exit.x) > bounds.left - margin
132
+ && Math.min(entry.y, corner.y, exit.y) < bounds.bottom + margin
133
+ && Math.max(entry.y, corner.y, exit.y) > bounds.top - margin);
134
+ };
135
+ if (maximum === 0 || !clear(0))
136
+ return 0;
137
+ if (clear(maximum))
138
+ return maximum;
139
+ let lower = 0;
140
+ let upper = maximum;
141
+ for (let iteration = 0; iteration < BEND_RADIUS_SEARCH_STEPS; iteration += 1) {
142
+ const candidate = (lower + upper) / 2;
143
+ if (clear(candidate))
144
+ lower = candidate;
145
+ else
146
+ upper = candidate;
147
+ }
148
+ return Math.floor(lower * BEND_RADIUS_PRECISION) / BEND_RADIUS_PRECISION;
149
+ }
150
+ /** Build sampled geometry for a cubic curve while retaining the exact cubic command for SVG output. */
151
+ function createCurveGeometry(start, controlStart, controlEnd, end) {
152
+ const samples = [];
153
+ const sampleCount = 32;
154
+ for (let index = 0; index <= sampleCount; index += 1) {
155
+ const progress = index / sampleCount;
156
+ const inverse = 1 - progress;
157
+ samples.push({
158
+ x: inverse ** 3 * start.x + 3 * inverse ** 2 * progress * controlStart.x + 3 * inverse * progress ** 2 * controlEnd.x + progress ** 3 * end.x,
159
+ y: inverse ** 3 * start.y + 3 * inverse ** 2 * progress * controlStart.y + 3 * inverse * progress ** 2 * controlEnd.y + progress ** 3 * end.y
160
+ });
161
+ }
162
+ const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
163
+ return createPathGeometry(samples, path, controlEnd, cubicBottom(start, controlStart, controlEnd, end));
164
+ }
165
+ /** Solve the cubic's vertical derivative; sampled flow points can miss the painted lower extremum. */
166
+ function cubicBottom(start, first, second, end) {
167
+ const scale = Math.max(Math.abs(start.y), Math.abs(first.y), Math.abs(second.y), Math.abs(end.y), 1);
168
+ const a = start.y / scale;
169
+ const b = first.y / scale;
170
+ const c = second.y / scale;
171
+ const d = end.y / scale;
172
+ const quadratic = -a + 3 * b - 3 * c + d;
173
+ const linear = 2 * (a - 2 * b + c);
174
+ const constant = b - a;
175
+ const discriminant = linear * linear - 4 * quadratic * constant;
176
+ const roots = quadratic === 0 ? linear === 0 ? [] : [-constant / linear]
177
+ : discriminant < 0 ? [] : [(-linear - Math.sqrt(discriminant)) / (2 * quadratic), (-linear + Math.sqrt(discriminant)) / (2 * quadratic)];
178
+ let bottom = Math.max(start.y, end.y);
179
+ for (const progress of roots) {
180
+ if (progress <= 0 || progress >= 1)
181
+ continue;
182
+ const inverse = 1 - progress;
183
+ const value = inverse ** 3 * start.y + 3 * inverse ** 2 * progress * first.y + 3 * inverse * progress ** 2 * second.y + progress ** 3 * end.y;
184
+ bottom = Math.max(bottom, value);
185
+ }
186
+ return bottom;
187
+ }
188
+ /** Build a two-segment cubic route used for opposite relationships; endpoint tangents remain exact port normals. */
189
+ function createSeparatedCurveGeometry(start, controlStart, midpoint, controlMidStart, controlMidEnd, controlEnd, end) {
190
+ const samples = [];
191
+ const sampleSegment = (first, firstControl, secondControl, last) => {
192
+ for (let index = 0; index <= 16; index += 1) {
193
+ if (samples.length > 0 && index === 0)
194
+ continue;
195
+ const progress = index / 16;
196
+ const inverse = 1 - progress;
197
+ samples.push({
198
+ x: inverse ** 3 * first.x + 3 * inverse ** 2 * progress * firstControl.x + 3 * inverse * progress ** 2 * secondControl.x + progress ** 3 * last.x,
199
+ y: inverse ** 3 * first.y + 3 * inverse ** 2 * progress * firstControl.y + 3 * inverse * progress ** 2 * secondControl.y + progress ** 3 * last.y
200
+ });
201
+ }
202
+ };
203
+ sampleSegment(start, controlStart, controlMidStart, midpoint);
204
+ sampleSegment(midpoint, controlMidEnd, controlEnd, end);
205
+ const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlMidStart.x)} ${formatNumber(controlMidStart.y)} ${formatNumber(midpoint.x)} ${formatNumber(midpoint.y)} C ${formatNumber(controlMidEnd.x)} ${formatNumber(controlMidEnd.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
206
+ return createPathGeometry(samples, path, controlEnd, Math.max(cubicBottom(start, controlStart, controlMidStart, midpoint), cubicBottom(midpoint, controlMidEnd, controlEnd, end)));
207
+ }
208
+ export function isPointNearPath(point, geometry, tolerance) {
209
+ for (let index = 1; index < geometry.points.length; index += 1)
210
+ if (distanceToSegment(point, geometry.points[index - 1], geometry.points[index]) <= tolerance)
211
+ return true;
212
+ return false;
213
+ }
214
+ function distanceToSegment(point, start, end) {
215
+ const dx = end.x - start.x;
216
+ const dy = end.y - start.y;
217
+ if (dx === 0 && dy === 0)
218
+ return Math.hypot(point.x - start.x, point.y - start.y);
219
+ const factor = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
220
+ return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy));
221
+ }
222
+ /**
223
+ * Orthogonal routes prefer centered bridges and single outside channels before obstacle search.
224
+ * They reserve outward endpoint segments and the final bend before the straight marker approach.
225
+ * Separate ancestor frames set outside clearance but are not obstacles.
226
+ * Curved and orthogonal input slots follow source position with document-order ties, independently of timeline visibility.
227
+ * Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
228
+ */
229
+ export function routeEdge(edge, from, to, nodes, routing) {
230
+ if (edge.route === "curved") {
231
+ const automatic = automaticPorts(from, to);
232
+ const fromDirection = edge.fromPort ?? automatic.from;
233
+ const toDirection = edge.toPort ?? automatic.to;
234
+ const start = portPoint(from, fromDirection);
235
+ const end = portPoint(to, toDirection, routing.targetOffset);
236
+ return curvedPath(start, end, fromDirection, toDirection, routing.reverseOffset);
237
+ }
238
+ if (edge.route !== "orthogonal") {
239
+ const points = edge.fromPort || edge.toPort
240
+ ? [portPoint(from, edge.fromPort ?? automaticPorts(from, to).from), portPoint(to, edge.toPort ?? automaticPorts(from, to).to)]
241
+ : straightPoints(from, to);
242
+ return createPathGeometry(points);
243
+ }
244
+ const obstacles = nodes
245
+ .filter((node) => node.visible && node.kind !== "group" && node.id !== from.id && node.id !== to.id)
246
+ .map(nodeBounds);
247
+ return createRoundedGeometry(orthogonalPortPath(edge, from, to, obstacles, routing.targetOffset, nodes), [...obstacles, nodeBounds(from), nodeBounds(to)], edge.style?.strokeWidth ?? 2);
248
+ }
249
+ /** Order inputs across each side by source position; retain hidden edges so reveals cannot move other arrow tips. */
250
+ export function routedTargetOffsets(edges, nodes) {
251
+ const groups = new Map();
252
+ for (const edge of edges) {
253
+ if (edge.route !== "orthogonal" && edge.route !== "curved")
254
+ continue;
255
+ const from = nodes.get(edge.from);
256
+ const to = nodes.get(edge.to);
257
+ if (!from || !to)
258
+ continue;
259
+ const side = edge.toPort ?? automaticPorts(from, to).to;
260
+ const key = JSON.stringify([to.id, side]);
261
+ const group = groups.get(key);
262
+ const position = side === "top" || side === "bottom" ? from.x + from.width / 2 : from.y + from.height / 2;
263
+ groups.set(key, { node: to, side, inputs: [...(group?.inputs ?? []), { id: edge.id, position }] });
264
+ }
265
+ const offsets = new Map();
266
+ for (const { node, side, inputs } of groups.values()) {
267
+ const span = side === "top" || side === "bottom" ? node.width : node.height;
268
+ const spacing = Math.min(PORT_SPACING, span / (2 * inputs.length));
269
+ [...inputs].sort((left, right) => left.position - right.position)
270
+ .forEach((edge, index) => offsets.set(edge.id, (index - (inputs.length - 1) / 2) * spacing));
271
+ }
272
+ return offsets;
273
+ }
274
+ function nodeBounds(node) {
275
+ return { left: node.x, top: node.y, right: node.x + node.width, bottom: node.y + node.height };
276
+ }
277
+ function routingBounds(bounds, side, approach = ROUTING_GAP) {
278
+ return {
279
+ left: bounds.left - (side === "left" ? approach : ROUTING_GAP),
280
+ top: bounds.top - (side === "top" ? approach : ROUTING_GAP),
281
+ right: bounds.right + (side === "right" ? approach : ROUTING_GAP),
282
+ bottom: bounds.bottom + (side === "bottom" ? approach : ROUTING_GAP)
283
+ };
284
+ }
285
+ /** Extend the target obstacle through the final bend and arrow approach so grid search cannot fold back into either reservation. */
286
+ function orthogonalPortPath(edge, from, to, obstacles, targetOffset, nodes) {
287
+ const automatic = automaticPorts(from, to);
288
+ const fromDirection = edge.fromPort ?? automatic.from;
289
+ const toDirection = edge.toPort ?? automatic.to;
290
+ const start = portPoint(from, fromDirection);
291
+ const end = portPoint(to, toDirection, targetOffset);
292
+ const approach = Math.max(MIN_ARROW_APPROACH, (edge.style?.strokeWidth ?? 2) * ARROW_MARKER_SIZE + ROUTING_GAP);
293
+ const fromOutline = nodeBounds(from);
294
+ const toOutline = nodeBounds(to);
295
+ const fromRun = availablePortRun(portStub(start, fromDirection, fromOutline), fromDirection, ROUTING_GAP, [...obstacles, toOutline]);
296
+ // Tight gaps clamp the preferred bridge lane to these stubs before the bend radius is reduced.
297
+ const toRun = availablePortRun(portStub(end, toDirection, toOutline), toDirection, approach + ORTHOGONAL_BEND_RADIUS, [...obstacles, fromOutline]);
298
+ const fromBounds = routingBounds(fromOutline, fromDirection, fromRun);
299
+ const toBounds = routingBounds(toOutline, toDirection, toRun);
300
+ const startStub = portStub(start, fromDirection, fromBounds);
301
+ const endStub = portStub(end, toDirection, toBounds);
302
+ const routingObstacles = [...obstacles.map((bounds) => routingBounds(bounds)), fromBounds, toBounds];
303
+ const preferred = preferredPortPath(startStub, endStub, fromDirection, toDirection, [fromOutline, toOutline, ...separateAncestorBounds(from, to, nodes)], approach);
304
+ const clear = preferred && preferred.every((point, index) => index === 0
305
+ || !blockedSegment(preferred[index - 1], point, routingObstacles));
306
+ const middle = clear ? preferred : orthogonalPath(startStub, endStub, routingObstacles);
307
+ return [start, ...middle, end];
308
+ }
309
+ /** Prefer a centered bridge or a single outside channel; obstacle search handles unsafe candidates. */
310
+ function preferredPortPath(start, end, from, to, outlines, clearance) {
311
+ const vertical = from === "top" || from === "bottom";
312
+ if (vertical !== (to === "top" || to === "bottom"))
313
+ return undefined;
314
+ const axis = vertical ? "y" : "x";
315
+ const normal = portNormal(from);
316
+ const sign = vertical ? normal.y : normal.x;
317
+ let lane;
318
+ if (from === to) {
319
+ const boundary = vertical ? (sign < 0 ? "top" : "bottom") : (sign < 0 ? "left" : "right");
320
+ const extents = outlines.map((bounds) => bounds[boundary]);
321
+ const naturalLane = (sign < 0 ? Math.min(...extents) : Math.max(...extents)) + sign * clearance;
322
+ // Ancestor clearance and endpoint reservations have separate responsibilities:
323
+ // move outward only when a reserved endpoint stub extends beyond the natural lane.
324
+ lane = sign < 0 ? Math.min(naturalLane, start[axis], end[axis]) : Math.max(naturalLane, start[axis], end[axis]);
325
+ }
326
+ else {
327
+ // Stubs bound the legal turning interval, including the target's arrow footprint.
328
+ if ((end[axis] - start[axis]) * sign < 0)
329
+ return undefined;
330
+ const first = outlines[0];
331
+ const last = outlines[1];
332
+ const sourceSide = vertical ? (sign < 0 ? first.top : first.bottom) : (sign < 0 ? first.left : first.right);
333
+ const targetSide = vertical ? (sign < 0 ? last.bottom : last.top) : (sign < 0 ? last.right : last.left);
334
+ const midpoint = (sourceSide + targetSide) / 2;
335
+ lane = Math.max(Math.min(start[axis], end[axis]), Math.min(Math.max(start[axis], end[axis]), midpoint));
336
+ }
337
+ return simplifyPoints([start, { ...start, [axis]: lane }, { ...end, [axis]: lane }, end]);
338
+ }
339
+ /** Only separate ancestor frames affect outside lanes; a shared container must not push an internal edge outside. */
340
+ function separateAncestorBounds(from, to, nodes) {
341
+ const byId = new Map(nodes.map((node) => [node.id, node]));
342
+ const ancestors = (node) => {
343
+ const result = [];
344
+ let parent = node.parentId ? byId.get(node.parentId) : undefined;
345
+ while (parent) {
346
+ result.push(parent);
347
+ parent = parent.parentId ? byId.get(parent.parentId) : undefined;
348
+ }
349
+ return result;
350
+ };
351
+ const sourceAncestors = ancestors(from);
352
+ const targetAncestors = ancestors(to);
353
+ const sourceIds = new Set([from.id, ...sourceAncestors.map(({ id }) => id)]);
354
+ const targetIds = new Set([to.id, ...targetAncestors.map(({ id }) => id)]);
355
+ return [...sourceAncestors.filter(({ id }) => !targetIds.has(id)),
356
+ ...targetAncestors.filter(({ id }) => !sourceIds.has(id))].map(nodeBounds);
357
+ }
358
+ /** Share narrow endpoint gaps instead of putting a reserved stub inside the opposite node. */
359
+ function availablePortRun(point, side, desired, obstacles) {
360
+ const vertical = side === "top" || side === "bottom";
361
+ const normal = portNormal(side);
362
+ return obstacles.reduce((run, bounds) => {
363
+ const transverse = vertical ? point.x : point.y;
364
+ const minimum = vertical ? bounds.left : bounds.top;
365
+ const maximum = vertical ? bounds.right : bounds.bottom;
366
+ if (transverse < minimum - ROUTING_GAP || transverse > maximum + ROUTING_GAP)
367
+ return run;
368
+ const distance = vertical
369
+ ? (side === "top" ? bounds.bottom - point.y : bounds.top - point.y) * normal.y
370
+ : (side === "left" ? bounds.right - point.x : bounds.left - point.x) * normal.x;
371
+ if (distance < 0)
372
+ return run;
373
+ return Math.min(run, Math.max(distance - ROUTING_GAP, distance / 2));
374
+ }, desired);
375
+ }
376
+ function portStub(point, direction, bounds) {
377
+ if (direction === "top")
378
+ return { x: point.x, y: bounds.top };
379
+ if (direction === "bottom")
380
+ return { x: point.x, y: bounds.bottom };
381
+ if (direction === "left")
382
+ return { x: bounds.left, y: point.y };
383
+ return { x: bounds.right, y: point.y };
384
+ }
385
+ /** Select cardinal sides from the dominant center-to-center axis for automatic routes. */
386
+ function automaticPorts(from, to) {
387
+ const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
388
+ const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
389
+ const horizontal = Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y);
390
+ if (horizontal) {
391
+ const right = toCenter.x >= fromCenter.x;
392
+ return { from: right ? "right" : "left", to: right ? "left" : "right" };
393
+ }
394
+ const bottom = toCenter.y >= fromCenter.y;
395
+ return { from: bottom ? "bottom" : "top", to: bottom ? "top" : "bottom" };
396
+ }
397
+ /** Return the actual outline point for a cardinal side, including ellipse contours. */
398
+ function portPoint(node, direction, offset = 0) {
399
+ const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
400
+ const vertical = direction === "top" || direction === "bottom";
401
+ const radius = (vertical ? node.width : node.height) / 2;
402
+ const contour = Math.sqrt(Math.max(0, 1 - (offset / radius) ** 2));
403
+ const inset = node.kind === "ellipse" ? (vertical ? node.height : node.width) / 2 * (1 - contour)
404
+ : node.kind === "cylinder" && vertical ? Math.min(CYLINDER_CAP_RADIUS, node.height / 4) * (1 - contour) : 0;
405
+ if (direction === "top")
406
+ return { x: center.x + offset, y: node.y + inset };
407
+ if (direction === "right")
408
+ return { x: node.x + node.width - inset, y: center.y + offset };
409
+ if (direction === "bottom")
410
+ return { x: center.x + offset, y: node.y + node.height - inset };
411
+ return { x: node.x + inset, y: center.y + offset };
412
+ }
413
+ /** Generate a smooth cubic route whose endpoint tangents follow port normals. */
414
+ function curvedPath(start, end, fromDirection, toDirection, separation) {
415
+ const distance = Math.hypot(end.x - start.x, end.y - start.y);
416
+ const tangentLength = Math.max(24, distance * 0.32);
417
+ const fromNormal = portNormal(fromDirection);
418
+ const toNormal = portNormal(toDirection);
419
+ const perpendicular = { x: -(end.y - start.y), y: end.x - start.x };
420
+ const perpendicularLength = Math.hypot(perpendicular.x, perpendicular.y) || 1;
421
+ const lane = separation * Math.min(72, Math.max(28, distance * 0.16));
422
+ if (separation !== 0) {
423
+ const midpoint = {
424
+ x: (start.x + end.x) / 2 + perpendicular.x / perpendicularLength * lane,
425
+ y: (start.y + end.y) / 2 + perpendicular.y / perpendicularLength * lane
426
+ };
427
+ const chord = { x: end.x - start.x, y: end.y - start.y };
428
+ const chordLength = Math.hypot(chord.x, chord.y) || 1;
429
+ const joinTangent = { x: chord.x / chordLength, y: chord.y / chordLength };
430
+ return createSeparatedCurveGeometry(start, { x: start.x + fromNormal.x * tangentLength, y: start.y + fromNormal.y * tangentLength }, midpoint, { x: midpoint.x - joinTangent.x * tangentLength * 0.5, y: midpoint.y - joinTangent.y * tangentLength * 0.5 }, { x: midpoint.x + joinTangent.x * tangentLength * 0.5, y: midpoint.y + joinTangent.y * tangentLength * 0.5 }, { x: end.x + toNormal.x * tangentLength, y: end.y + toNormal.y * tangentLength }, end);
431
+ }
432
+ const controlStart = {
433
+ x: start.x + fromNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
434
+ y: start.y + fromNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
435
+ };
436
+ const controlEnd = {
437
+ x: end.x + toNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
438
+ y: end.y + toNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
439
+ };
440
+ return createCurveGeometry(start, controlStart, controlEnd, end);
441
+ }
442
+ function portNormal(direction) {
443
+ if (direction === "top")
444
+ return { x: 0, y: -1 };
445
+ if (direction === "right")
446
+ return { x: 1, y: 0 };
447
+ if (direction === "bottom")
448
+ return { x: 0, y: 1 };
449
+ return { x: -1, y: 0 };
450
+ }
451
+ /**
452
+ * Breadth-first search the grid of endpoints and obstacle boundaries, using fixed neighbor order for determinism.
453
+ * Grid-step search does not guarantee shortest geometric length or fewest bends; fall back to a fixed elbow if blocked.
454
+ */
455
+ function orthogonalPath(start, end, obstacles) {
456
+ const xs = uniqueSorted([start.x, end.x, ...obstacles.flatMap((rect) => [rect.left, rect.right])]);
457
+ const ys = uniqueSorted([start.y, end.y, ...obstacles.flatMap((rect) => [rect.top, rect.bottom])]);
458
+ const xIndex = new Map(xs.map((value, index) => [value, index]));
459
+ const yIndex = new Map(ys.map((value, index) => [value, index]));
460
+ const startKey = pointKey(start);
461
+ const endKey = pointKey(end);
462
+ const queue = [start];
463
+ const previous = new Map([[startKey, undefined]]);
464
+ const preferredHorizontal = Math.abs(end.x - start.x) >= Math.abs(end.y - start.y);
465
+ const directions = preferredHorizontal
466
+ ? [[1, 0], [0, -1], [0, 1], [-1, 0]]
467
+ : [[0, 1], [1, 0], [-1, 0], [0, -1]];
468
+ while (queue.length > 0) {
469
+ const current = queue.shift();
470
+ if (pointKey(current) === endKey)
471
+ break;
472
+ const currentX = xIndex.get(current.x);
473
+ const currentY = yIndex.get(current.y);
474
+ if (currentX === undefined || currentY === undefined)
475
+ continue;
476
+ for (const [dx, dy] of directions) {
477
+ const nextX = xs[currentX + dx];
478
+ const nextY = ys[currentY + dy];
479
+ if (nextX === undefined || nextY === undefined)
480
+ continue;
481
+ const next = { x: nextX, y: nextY };
482
+ const key = pointKey(next);
483
+ if (previous.has(key) || isInsideObstacle(next, obstacles) || blockedSegment(current, next, obstacles))
484
+ continue;
485
+ previous.set(key, current);
486
+ queue.push(next);
487
+ }
488
+ }
489
+ const points = [];
490
+ if (previous.has(endKey)) {
491
+ let current = end;
492
+ while (current) {
493
+ points.push(current);
494
+ current = previous.get(pointKey(current));
495
+ }
496
+ points.reverse();
497
+ }
498
+ else {
499
+ const middle = preferredHorizontal ? { x: end.x, y: start.y } : { x: start.x, y: end.y };
500
+ points.push(start, middle, end);
501
+ }
502
+ return removeMicroTurns(points, obstacles);
503
+ }
504
+ /** Replace tiny obstacle-detour turns with a wider elbow when the replacement remains collision-free. */
505
+ function removeMicroTurns(points, obstacles) {
506
+ const result = [...points];
507
+ const minimum = ROUTING_GAP * 2;
508
+ for (let index = 1; index + 2 < result.length; index += 1) {
509
+ const before = result[index - 1];
510
+ const first = result[index];
511
+ const second = result[index + 1];
512
+ const after = result[index + 2];
513
+ const shortLength = Math.abs(second.x - first.x) + Math.abs(second.y - first.y);
514
+ if (shortLength >= minimum)
515
+ continue;
516
+ const candidate = before.x === first.x
517
+ ? { x: first.x, y: after.y }
518
+ : { x: after.x, y: first.y };
519
+ if (isInsideObstacle(candidate, obstacles) || blockedSegment(before, candidate, obstacles) || blockedSegment(candidate, after, obstacles))
520
+ continue;
521
+ result.splice(index, 2, candidate);
522
+ index = Math.max(0, index - 2);
523
+ }
524
+ return result;
525
+ }
526
+ /** Remove consecutive duplicates and collinear intermediate points to keep SVG paths compact. */
527
+ function pointsToPath(points) {
528
+ return simplifyPoints(points).map((point, index) => `${index === 0 ? "M" : "L"} ${formatNumber(point.x)} ${formatNumber(point.y)}`).join(" ");
529
+ }
530
+ /** Keep collinear reversals: removing an extremum would shorten flow distance and truncate path bounds. */
531
+ function simplifyPoints(points) {
532
+ const compact = [];
533
+ for (const point of points) {
534
+ const previous = compact.at(-1);
535
+ if (!previous || previous.x !== point.x || previous.y !== point.y)
536
+ compact.push(point);
537
+ }
538
+ const simplified = [];
539
+ for (const point of compact) {
540
+ const previous = simplified.at(-1);
541
+ const beforePrevious = simplified.at(-2);
542
+ const continuesDirection = previous && beforePrevious
543
+ && (previous.x - beforePrevious.x) * (point.x - previous.x) + (previous.y - beforePrevious.y) * (point.y - previous.y) >= 0;
544
+ if (previous && beforePrevious && continuesDirection && (beforePrevious.x === previous.x && previous.x === point.x || beforePrevious.y === previous.y && previous.y === point.y)) {
545
+ simplified[simplified.length - 1] = point;
546
+ }
547
+ else {
548
+ simplified.push(point);
549
+ }
550
+ }
551
+ return simplified;
552
+ }
553
+ /** Obstacles already include ROUTING_GAP; allow boundary contact and reject only segments crossing their interiors. */
554
+ function blockedSegment(start, end, obstacles) {
555
+ for (const obstacle of obstacles) {
556
+ if (start.x === end.x) {
557
+ if (start.x > obstacle.left && start.x < obstacle.right && rangesOverlap(start.y, end.y, obstacle.top, obstacle.bottom))
558
+ return true;
559
+ }
560
+ else if (start.y === end.y && start.y > obstacle.top && start.y < obstacle.bottom && rangesOverlap(start.x, end.x, obstacle.left, obstacle.right)) {
561
+ return true;
562
+ }
563
+ }
564
+ return false;
565
+ }
566
+ function isInsideObstacle(point, obstacles) {
567
+ return obstacles.some((obstacle) => point.x > obstacle.left && point.x < obstacle.right && point.y > obstacle.top && point.y < obstacle.bottom);
568
+ }
569
+ function rangesOverlap(firstStart, firstEnd, secondStart, secondEnd) {
570
+ return Math.max(Math.min(firstStart, firstEnd), secondStart) < Math.min(Math.max(firstStart, firstEnd), secondEnd);
571
+ }
572
+ function pointKey(point) {
573
+ return `${point.x},${point.y}`;
574
+ }
575
+ function uniqueSorted(values) {
576
+ return [...new Set(values)].sort((left, right) => left - right);
577
+ }
578
+ /** Project straight connections onto the ellipse contour, or the bounding rectangle for other shapes. */
579
+ function boundaryPoint(node, toward) {
580
+ const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
581
+ const dx = toward.x - center.x;
582
+ const dy = toward.y - center.y;
583
+ if (dx === 0 && dy === 0)
584
+ return center;
585
+ const normalizedX = dx / (node.width / 2);
586
+ const normalizedY = dy / (node.height / 2);
587
+ const scale = 1 / (node.kind === "ellipse"
588
+ ? Math.hypot(normalizedX, normalizedY)
589
+ : Math.max(Math.abs(normalizedX), Math.abs(normalizedY)));
590
+ return { x: center.x + dx * scale, y: center.y + dy * scale };
591
+ }
592
+ export function pathLabelAnchor(geometry) {
593
+ const points = geometry.points;
594
+ if (points.length < 2)
595
+ return { x: 0, y: -12 };
596
+ let longest = { start: points[0], end: points[1], length: 0 };
597
+ for (let index = 1; index < points.length; index += 1) {
598
+ const start = points[index - 1];
599
+ const end = points[index];
600
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
601
+ if (length > longest.length)
602
+ longest = { start, end, length };
603
+ }
604
+ const horizontal = longest.start.y === longest.end.y;
605
+ return {
606
+ x: (longest.start.x + longest.end.x) / 2 + (horizontal ? 0 : 20),
607
+ y: (longest.start.y + longest.end.y) / 2 + (horizontal ? -12 : 0)
608
+ };
609
+ }