dsh-archify-projects 0.0.0-stage → 0.1.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 (148) hide show
  1. package/README.md +204 -2
  2. package/cordis.patch.yml +99 -0
  3. package/dist/client.js +2261 -0
  4. package/dist/client.js.map +1 -0
  5. package/docs/CONTAINER.md +197 -0
  6. package/docs/DESIGN.md +905 -0
  7. package/docs/HANDOVER.md +391 -0
  8. package/docs/PUBLISH.md +107 -0
  9. package/icon.svg +12 -0
  10. package/lib/host/archify-skill.js +116 -0
  11. package/lib/host/archify-skill.js.map +1 -0
  12. package/lib/host/config.js +95 -0
  13. package/lib/host/config.js.map +1 -0
  14. package/lib/host/context.js +28 -0
  15. package/lib/host/context.js.map +1 -0
  16. package/lib/host/git.js +185 -0
  17. package/lib/host/git.js.map +1 -0
  18. package/lib/host/http.js +184 -0
  19. package/lib/host/http.js.map +1 -0
  20. package/lib/host/index.js +90 -0
  21. package/lib/host/index.js.map +1 -0
  22. package/lib/host/policy.js +124 -0
  23. package/lib/host/policy.js.map +1 -0
  24. package/lib/host/routes.js +667 -0
  25. package/lib/host/routes.js.map +1 -0
  26. package/lib/host/runner.js +913 -0
  27. package/lib/host/runner.js.map +1 -0
  28. package/lib/host/store.js +370 -0
  29. package/lib/host/store.js.map +1 -0
  30. package/lib/host/workspace.js +65 -0
  31. package/lib/host/workspace.js.map +1 -0
  32. package/lib/shared/format.js +138 -0
  33. package/lib/shared/format.js.map +1 -0
  34. package/lib/shared/prompt.js +257 -0
  35. package/lib/shared/prompt.js.map +1 -0
  36. package/lib/shared/protocol.js +31 -0
  37. package/lib/shared/protocol.js.map +1 -0
  38. package/lib/shared/types.js +173 -0
  39. package/lib/shared/types.js.map +1 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +98 -4
  43. package/skills/archify/LICENSE +22 -0
  44. package/skills/archify/SKILL.md +109 -0
  45. package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
  46. package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
  47. package/skills/archify/assets/template.html +13709 -0
  48. package/skills/archify/bin/archify.mjs +6936 -0
  49. package/skills/archify/bin/delivery-update.mjs +91 -0
  50. package/skills/archify/bin/finalize.mjs +895 -0
  51. package/skills/archify/bin/open-artifact.mjs +146 -0
  52. package/skills/archify/bin/preview.mjs +969 -0
  53. package/skills/archify/bin/recover-output.mjs +41 -0
  54. package/skills/archify/bin/visual-check.mjs +2667 -0
  55. package/skills/archify/brand-marks/README.md +31 -0
  56. package/skills/archify/brand-marks/catalog.json +131 -0
  57. package/skills/archify/delta/architecture-delta.mjs +1313 -0
  58. package/skills/archify/examples/agent-run.lifecycle.json +55 -0
  59. package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
  60. package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
  61. package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
  62. package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
  63. package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
  64. package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
  65. package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
  66. package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
  67. package/skills/archify/examples/event-stream.dataflow.json +52 -0
  68. package/skills/archify/examples/holiday-planning.workflow.json +143 -0
  69. package/skills/archify/examples/incident-response.workflow.json +59 -0
  70. package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
  71. package/skills/archify/examples/locales/de.json +425 -0
  72. package/skills/archify/examples/locales/es.json +440 -0
  73. package/skills/archify/examples/locales/fr.json +425 -0
  74. package/skills/archify/examples/locales/fr.partial.json +22 -0
  75. package/skills/archify/examples/locales/it.json +425 -0
  76. package/skills/archify/examples/locales/ja.json +425 -0
  77. package/skills/archify/examples/locales/ko.json +440 -0
  78. package/skills/archify/examples/locales/pt.json +425 -0
  79. package/skills/archify/examples/locales/ru.json +425 -0
  80. package/skills/archify/examples/product-analytics.dataflow.json +71 -0
  81. package/skills/archify/examples/production-deployment.architecture.json +51 -0
  82. package/skills/archify/examples/release-delivery.workflow.json +57 -0
  83. package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
  84. package/skills/archify/examples/web-app-rendered.html +13808 -0
  85. package/skills/archify/examples/web-app.architecture.json +41 -0
  86. package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
  87. package/skills/archify/migrations/workflow-v2.mjs +280 -0
  88. package/skills/archify/package.json +17 -0
  89. package/skills/archify/recipes/scenarios.mjs +430 -0
  90. package/skills/archify/references/architecture-layout-repair.md +18 -0
  91. package/skills/archify/references/authoring-contract.md +419 -0
  92. package/skills/archify/references/authoring-defaults.md +41 -0
  93. package/skills/archify/references/brand-marks.md +82 -0
  94. package/skills/archify/references/delivery-contract.md +588 -0
  95. package/skills/archify/references/repository-authoring.md +105 -0
  96. package/skills/archify/references/update-awareness.md +12 -0
  97. package/skills/archify/references/viewer-runtime.md +35 -0
  98. package/skills/archify/renderers/architecture/grid.mjs +62 -0
  99. package/skills/archify/renderers/architecture/labels.mjs +215 -0
  100. package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
  101. package/skills/archify/renderers/architecture/routing.mjs +1157 -0
  102. package/skills/archify/renderers/dataflow/README.md +105 -0
  103. package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
  104. package/skills/archify/renderers/lifecycle/README.md +171 -0
  105. package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
  106. package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
  107. package/skills/archify/renderers/sequence/README.md +129 -0
  108. package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
  109. package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
  110. package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
  111. package/skills/archify/renderers/shared/cli.mjs +451 -0
  112. package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
  113. package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
  114. package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
  115. package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  116. package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
  117. package/skills/archify/renderers/shared/geometry.mjs +1941 -0
  118. package/skills/archify/renderers/shared/i18n.mjs +603 -0
  119. package/skills/archify/renderers/shared/layout-report.mjs +42 -0
  120. package/skills/archify/renderers/shared/legend.mjs +217 -0
  121. package/skills/archify/renderers/shared/output-path.mjs +440 -0
  122. package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
  123. package/skills/archify/renderers/shared/portable-path.mjs +408 -0
  124. package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
  125. package/skills/archify/renderers/shared/repository-location.mjs +59 -0
  126. package/skills/archify/renderers/shared/route-quality.mjs +614 -0
  127. package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
  128. package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
  129. package/skills/archify/renderers/shared/text-fit.mjs +105 -0
  130. package/skills/archify/renderers/shared/utils.mjs +254 -0
  131. package/skills/archify/renderers/shared/validator.mjs +86 -0
  132. package/skills/archify/renderers/workflow/README.md +301 -0
  133. package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
  134. package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
  135. package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  136. package/skills/archify/schemas/README.md +256 -0
  137. package/skills/archify/schemas/architecture.schema.json +152 -0
  138. package/skills/archify/schemas/common.schema.json +181 -0
  139. package/skills/archify/schemas/dataflow.schema.json +254 -0
  140. package/skills/archify/schemas/lifecycle.schema.json +280 -0
  141. package/skills/archify/schemas/sequence.schema.json +234 -0
  142. package/skills/archify/schemas/workflow.schema.json +439 -0
  143. package/skills/archify/scripts/check-render-output.mjs +1388 -0
  144. package/skills/archify/scripts/check-update.mjs +1710 -0
  145. package/skills/archify/scripts/delivery-update-child.mjs +23 -0
  146. package/skills/archify/scripts/render-examples.mjs +26 -0
  147. package/skills/archify/scripts/update-contract.mjs +182 -0
  148. package/skills/archify/skill-release.json +10 -0
@@ -0,0 +1,1157 @@
1
+ // Internal architecture router shared by rendering and geometry inspection.
2
+ // Create a new router when measured boxes or connections change: port spreading
3
+ // is computed once and route results are cached for this scene.
4
+
5
+ import {
6
+ segmentIntersectsRect,
7
+ anchor,
8
+ automaticPortSpread,
9
+ automaticPortRhythmBridge,
10
+ defaultFromSide,
11
+ defaultToSide,
12
+ chosenSide,
13
+ properSegmentIntersection,
14
+ routeHonorsEndpointSides,
15
+ normalizeRoutePoints,
16
+ rectsOverlap,
17
+ roundedPath,
18
+ collectBorderRuns,
19
+ collectRouteRhythmIssues,
20
+ frameBorderSegments,
21
+ } from '../shared/geometry.mjs';
22
+ import { shortestOrthogonalGridRoute } from '../shared/route-quality.mjs';
23
+
24
+ /**
25
+ * Router bound to one set of measured component boxes.
26
+ *
27
+ * @param {Map<string, {x,y,width,height,cx,cy}>} components measured boxes by id
28
+ * @param {Array<object>} connections the connection list to spread ports across
29
+ * @param {object} [options]
30
+ * @param {Array<{x,y,width,height,radius?}>} [options.frames] structural frames
31
+ * (boundaries) whose borders an automatic route may cross but never follow
32
+ * @param {number} [options.interiorSegmentPx] showcase floor for interior segments
33
+ * @param {number} [options.microSegmentPx] floor for any segment
34
+ * @param {(conn: object, points: number[][], context: {routes: number[][][], labels: object[]}) => object|null} [options.labelRectFor]
35
+ * default label rect of a routed relationship given the routes and label
36
+ * rects resolved so far; later automatic routes keep clear of it so a dense
37
+ * fan-out does not leave the label nowhere to go
38
+ */
39
+ export function createRouter(components, connections = [], {
40
+ frames = [],
41
+ interiorSegmentPx = 16,
42
+ microSegmentPx = 8,
43
+ labelRectFor = null,
44
+ distinctAutomaticPorts = false,
45
+ preferReadableRoutes = false,
46
+ } = {}) {
47
+ const frameBorders = frames.flatMap((frame) => frameBorderSegments(frame));
48
+ const LABEL_CLEARANCE = 4;
49
+ // Labels of already-routed relationships, reserved while planning the rest.
50
+ let reservedLabels = [];
51
+ let honourReservedLabels = true;
52
+ let allowGridSearch = true;
53
+
54
+ function routeClearsReservedLabels(conn, points) {
55
+ if (!honourReservedLabels) return true;
56
+ for (const entry of reservedLabels) {
57
+ if (entry.conn === conn) continue;
58
+ for (let index = 0; index < points.length - 1; index += 1) {
59
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, entry.rect, LABEL_CLEARANCE)) {
60
+ return false;
61
+ }
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+
67
+ // The grid search adds its own 2px component clearance; pre-expand so a
68
+ // reserved label keeps the same 4px clearance the placement pass demands.
69
+ function reservedLabelObstacles(gridClearance) {
70
+ if (!honourReservedLabels) return [];
71
+ const grow = LABEL_CLEARANCE - gridClearance;
72
+ return reservedLabels.map(({ rect }) => ({
73
+ x: rect.x - grow,
74
+ y: rect.y - grow,
75
+ width: rect.width + grow * 2,
76
+ height: rect.height + grow * 2,
77
+ }));
78
+ }
79
+
80
+ // Automatic routes are held to the same composition floors the showcase
81
+ // gate enforces afterwards. Accepting a route here that the gate rejects
82
+ // only hands the author a hand-routing repair the planner could have made.
83
+ function routeMeetsCompositionFloors(points) {
84
+ if (collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx, microSegmentPx }).length) {
85
+ return false;
86
+ }
87
+ return !frames.length || collectBorderRuns({ routedRelations: [{ points }], frames }).length === 0;
88
+ }
89
+ const planningMetrics = {
90
+ routeCount: 0,
91
+ explicitRouteCount: 0,
92
+ automaticRouteCount: 0,
93
+ gridSearchCount: 0,
94
+ gridRoutedCount: 0,
95
+ gridCandidateNodeCount: 0,
96
+ gridUsableNodeCount: 0,
97
+ gridEdgeCount: 0,
98
+ gridVisitedNodeCount: 0,
99
+ avoidedSegmentCount: 0,
100
+ conflictFallbackCount: 0,
101
+ maximumGridSearchCount: 64,
102
+ gridBudgetExhaustedCount: 0,
103
+ crossoverRoutedCount: 0,
104
+ readabilityCandidateCount: 0,
105
+ readabilityImprovedCount: 0,
106
+ reciprocalCandidateCount: 0,
107
+ reciprocalImprovedCount: 0,
108
+ gridAttempts: [],
109
+ };
110
+
111
+ // ---- Connection routing ------------------------------------------------------
112
+ function routeClearsComponents(conn, points, clearance = 2) {
113
+ const endpointIds = new Set([conn.from, conn.to]);
114
+ for (const component of components.values()) {
115
+ if (endpointIds.has(component.id)) continue;
116
+ for (let index = 0; index < points.length - 1; index += 1) {
117
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
118
+ return false;
119
+ }
120
+ }
121
+ }
122
+ return routeClearsReservedLabels(conn, points);
123
+ }
124
+
125
+ function routeClearsEndpointComponents(points, from, to) {
126
+ const lastSegment = points.length - 2;
127
+ for (let index = 0; index <= lastSegment; index += 1) {
128
+ const segment = { start: points[index], end: points[index + 1] };
129
+ if (index > 0 && segmentIntersectsRect(segment, from)) return false;
130
+ if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ function relationshipsShareEndpoint(left, right) {
136
+ return left.from === right.from
137
+ || left.from === right.to
138
+ || left.to === right.from
139
+ || left.to === right.to;
140
+ }
141
+
142
+ function collinearOverlapLength(leftStart, leftEnd, rightStart, rightEnd) {
143
+ const epsilon = 0.0001;
144
+ if (Math.abs(leftStart[0] - leftEnd[0]) <= epsilon
145
+ && Math.abs(rightStart[0] - rightEnd[0]) <= epsilon
146
+ && Math.abs(leftStart[0] - rightStart[0]) <= epsilon) {
147
+ return Math.max(0,
148
+ Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
149
+ - Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
150
+ }
151
+ if (Math.abs(leftStart[1] - leftEnd[1]) <= epsilon
152
+ && Math.abs(rightStart[1] - rightEnd[1]) <= epsilon
153
+ && Math.abs(leftStart[1] - rightStart[1]) <= epsilon) {
154
+ return Math.max(0,
155
+ Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
156
+ - Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
157
+ }
158
+ return 0;
159
+ }
160
+
161
+ function orthogonalTouchOnResolvedInterior(start, end, resolvedStart, resolvedEnd) {
162
+ const epsilon = 0.0001;
163
+ const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
164
+ const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
165
+ const resolvedHorizontal = Math.abs(resolvedStart[1] - resolvedEnd[1]) <= epsilon;
166
+ const resolvedVertical = Math.abs(resolvedStart[0] - resolvedEnd[0]) <= epsilon;
167
+ if (candidateHorizontal && resolvedVertical) {
168
+ const x = resolvedStart[0];
169
+ const y = start[1];
170
+ return x >= Math.min(start[0], end[0]) - epsilon
171
+ && x <= Math.max(start[0], end[0]) + epsilon
172
+ && y > Math.min(resolvedStart[1], resolvedEnd[1]) + epsilon
173
+ && y < Math.max(resolvedStart[1], resolvedEnd[1]) - epsilon;
174
+ }
175
+ if (candidateVertical && resolvedHorizontal) {
176
+ const x = start[0];
177
+ const y = resolvedStart[1];
178
+ return y >= Math.min(start[1], end[1]) - epsilon
179
+ && y <= Math.max(start[1], end[1]) + epsilon
180
+ && x > Math.min(resolvedStart[0], resolvedEnd[0]) + epsilon
181
+ && x < Math.max(resolvedStart[0], resolvedEnd[0]) - epsilon;
182
+ }
183
+ return false;
184
+ }
185
+
186
+ function unrelatedResolvedRoutes(conn, resolvedRoutes) {
187
+ return resolvedRoutes.filter((entry) => !relationshipsShareEndpoint(conn, entry.conn));
188
+ }
189
+
190
+ function routeConflictsWithResolved(conn, points, resolvedRoutes) {
191
+ const unrelated = unrelatedResolvedRoutes(conn, resolvedRoutes);
192
+ for (const entry of unrelated) {
193
+ for (let left = 0; left < points.length - 1; left += 1) {
194
+ for (let right = 0; right < entry.points.length - 1; right += 1) {
195
+ if (properSegmentIntersection(
196
+ points[left],
197
+ points[left + 1],
198
+ entry.points[right],
199
+ entry.points[right + 1],
200
+ )) return true;
201
+ if (orthogonalTouchOnResolvedInterior(
202
+ points[left],
203
+ points[left + 1],
204
+ entry.points[right],
205
+ entry.points[right + 1],
206
+ )) return true;
207
+ if (collinearOverlapLength(
208
+ points[left],
209
+ points[left + 1],
210
+ entry.points[right],
211
+ entry.points[right + 1],
212
+ ) >= 8) return true;
213
+ }
214
+ }
215
+ }
216
+ return false;
217
+ }
218
+
219
+ function routeOverlapsResolved(conn, points, resolvedRoutes) {
220
+ // A common destination does not make two independently labelled routes a
221
+ // bus. Keep their corridors distinct too; explicit routes bypass planning.
222
+ for (const entry of resolvedRoutes) {
223
+ if (relationshipsShareEndpoint(conn, entry.conn)
224
+ && (!distinctAutomaticPorts || hasAuthoredRouteGeometry(entry.conn) || entry.conn.labelAt || conn.labelAt)) continue;
225
+ for (let left = 0; left < points.length - 1; left += 1) {
226
+ for (let right = 0; right < entry.points.length - 1; right += 1) {
227
+ if (collinearOverlapLength(
228
+ points[left],
229
+ points[left + 1],
230
+ entry.points[right],
231
+ entry.points[right + 1],
232
+ ) >= 8) return true;
233
+ }
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+
239
+ const OUTWARD_SIDE_VECTOR = {
240
+ left: [-1, 0],
241
+ right: [1, 0],
242
+ top: [0, -1],
243
+ bottom: [0, 1],
244
+ };
245
+
246
+ function outwardStub(point, side, distance = 24) {
247
+ const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
248
+ return [point[0] + dx * distance, point[1] + dy * distance];
249
+ }
250
+
251
+ function collinearBacktrack(a, b, c) {
252
+ const first = [b[0] - a[0], b[1] - a[1]];
253
+ const second = [c[0] - b[0], c[1] - b[1]];
254
+ const cross = first[0] * second[1] - first[1] * second[0];
255
+ const dot = first[0] * second[0] + first[1] * second[1];
256
+ return Math.abs(cross) <= 0.0001 && dot < -0.0001;
257
+ }
258
+
259
+ function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
260
+ const startStub = outwardStub(start, fromSide);
261
+ const endStub = outwardStub(end, toSide);
262
+ const rawCandidates = [];
263
+ const minimumBridge = 16;
264
+ const verticalSides = new Set(['top', 'bottom']);
265
+ const horizontalSides = new Set(['left', 'right']);
266
+
267
+ // Port spreading can leave parallel-side anchors only a few pixels apart.
268
+ // Route through a bounded outside channel so we keep both endpoint normals
269
+ // without introducing a tiny, noisy connector between the two stubs.
270
+ if (verticalSides.has(fromSide) && verticalSides.has(toSide)
271
+ && Math.abs(start[0] - end[0]) < minimumBridge) {
272
+ for (const channelX of [
273
+ Math.max(start[0], end[0]) + minimumBridge,
274
+ Math.min(start[0], end[0]) - minimumBridge,
275
+ ]) {
276
+ rawCandidates.push([
277
+ startStub,
278
+ [channelX, startStub[1]],
279
+ [channelX, endStub[1]],
280
+ endStub,
281
+ ]);
282
+ }
283
+ }
284
+ if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
285
+ && Math.abs(start[1] - end[1]) < minimumBridge) {
286
+ for (const channelY of [
287
+ Math.max(start[1], end[1]) + minimumBridge,
288
+ Math.min(start[1], end[1]) - minimumBridge,
289
+ ]) {
290
+ rawCandidates.push([
291
+ startStub,
292
+ [startStub[0], channelY],
293
+ [endStub[0], channelY],
294
+ endStub,
295
+ ]);
296
+ }
297
+ }
298
+
299
+ rawCandidates.push(
300
+ [startStub, [endStub[0], startStub[1]], endStub],
301
+ [startStub, [startStub[0], endStub[1]], endStub],
302
+ );
303
+ return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
304
+ .filter((points) => points.length >= 2)
305
+ .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
306
+ .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
307
+ .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
308
+ .map((points) => points.slice(1, -1));
309
+ }
310
+
311
+ const AUTOMATIC_PORT_CORNER_GUTTER = 16;
312
+ const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16;
313
+
314
+ function portHasCornerClearance(rect, side, point) {
315
+ if (side === 'left' || side === 'right') {
316
+ const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2);
317
+ return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
318
+ }
319
+ if (side === 'top' || side === 'bottom') {
320
+ const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2);
321
+ return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
322
+ }
323
+ return false;
324
+ }
325
+
326
+ function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
327
+ const hasExplicitGeometry = (
328
+ conn.via
329
+ || (conn.route && conn.route !== 'auto')
330
+ || conn.channelX !== undefined
331
+ || conn.channelY !== undefined
332
+ || conn.labelAt
333
+ );
334
+ const horizontallyFacing = (
335
+ (fromSide === 'right' && toSide === 'left')
336
+ || (fromSide === 'left' && toSide === 'right')
337
+ );
338
+ const verticallyFacing = (
339
+ (fromSide === 'bottom' && toSide === 'top')
340
+ || (fromSide === 'top' && toSide === 'bottom')
341
+ );
342
+ if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
343
+
344
+ const fromSpread = Boolean(ports?.from);
345
+ const toSpread = Boolean(ports?.to);
346
+ if (fromSpread && toSpread) return { start, end };
347
+ const hasExplicitSides = (
348
+ (conn.fromSide && conn.fromSide !== 'auto')
349
+ || (conn.toSide && conn.toSide !== 'auto')
350
+ );
351
+ if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
352
+
353
+ const alignmentDelta = horizontallyFacing
354
+ ? Math.abs(start[1] - end[1])
355
+ : Math.abs(start[0] - end[0]);
356
+ if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end };
357
+
358
+ // Keep the shared endpoint's distinct spread slot and move only the
359
+ // relationship's unshared endpoint onto that axis. With no spread endpoint,
360
+ // retain the existing least-movement choice between the two facing sides.
361
+ // If both endpoints are shared, preserve the outside bridge so no competing
362
+ // port is silently collapsed.
363
+ const alignEndToStart = horizontallyFacing
364
+ ? { start, end: [end[0], start[1]] }
365
+ : { start, end: [start[0], end[1]] };
366
+ const alignStartToEnd = horizontallyFacing
367
+ ? { start: [start[0], end[1]], end }
368
+ : { start: [end[0], start[1]], end };
369
+ const candidates = fromSpread
370
+ ? [alignEndToStart]
371
+ : toSpread
372
+ ? [alignStartToEnd]
373
+ : [alignEndToStart, alignStartToEnd];
374
+ for (const candidate of candidates) {
375
+ const points = [candidate.start, candidate.end];
376
+ if (portHasCornerClearance(from, fromSide, candidate.start)
377
+ && portHasCornerClearance(to, toSide, candidate.end)
378
+ && routeHonorsEndpointSides(points, fromSide, toSide)
379
+ && routeClearsEndpointComponents(points, from, to)
380
+ && routeClearsComponents(conn, points)) {
381
+ return candidate;
382
+ }
383
+ }
384
+ return { start, end };
385
+ }
386
+
387
+ // A reciprocal pair is repaired jointly after planning, so crossings that
388
+ // involve it are not final yet; keep the established first choice there.
389
+ const reciprocal = (conn) => connections.some((other) => other.from === conn.to && other.to === conn.from);
390
+ function siblingCrossings(conn, points, resolvedRoutes) {
391
+ if (reciprocal(conn)) return 0;
392
+ return crossingCount(conn, points, resolvedRoutes.filter((entry) => !reciprocal(entry.conn)));
393
+ }
394
+
395
+ function routeVia(conn, from, to, start, end, fromSide, toSide, resolvedRoutes = []) {
396
+ if (conn.via) return conn.via;
397
+ switch (conn.route || 'auto') {
398
+ case 'straight':
399
+ return [];
400
+ case 'orthogonal-h': {
401
+ const midX = (start[0] + end[0]) / 2;
402
+ return [[midX, start[1]], [midX, end[1]]];
403
+ }
404
+ case 'orthogonal-v': {
405
+ const midY = (start[1] + end[1]) / 2;
406
+ return [[start[0], midY], [end[0], midY]];
407
+ }
408
+ case 'auto':
409
+ default: {
410
+ // Direct line unless the anchors are clearly orthogonal-friendly.
411
+ const deltaX = Math.abs(start[0] - end[0]);
412
+ const deltaY = Math.abs(start[1] - end[1]);
413
+ if (deltaX < 4 || deltaY < 4) {
414
+ const direct = [start, end];
415
+ if (routeHonorsEndpointSides(direct, fromSide, toSide)
416
+ && routeClearsEndpointComponents(direct, from, to)
417
+ && routeClearsComponents(conn, direct)
418
+ && routeMeetsCompositionFloors(direct)
419
+ && !routeConflictsWithResolved(conn, direct, resolvedRoutes)) return [];
420
+ }
421
+
422
+ const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
423
+ accept: (points) => (
424
+ routeClearsEndpointComponents(points, from, to)
425
+ && routeClearsComponents(conn, points)
426
+ && routeMeetsCompositionFloors(points)
427
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)
428
+ ),
429
+ });
430
+ if (rhythmBridge) return rhythmBridge.slice(1, -1);
431
+
432
+ // Automatic port spreading can leave otherwise aligned endpoints only a
433
+ // few pixels apart. A midpoint route would split that tiny difference
434
+ // into two unreadable endpoint stubs, so take a bounded outside channel
435
+ // when both anchors sit on parallel component sides.
436
+ const minimumStub = 8;
437
+ const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
438
+ const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
439
+ if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
440
+ const outsideChannels = [
441
+ Math.max(start[0], end[0]) + minimumStub * 2,
442
+ Math.min(start[0], end[0]) - minimumStub * 2,
443
+ ];
444
+ for (const channelX of outsideChannels) {
445
+ const candidate = [[channelX, start[1]], [channelX, end[1]]];
446
+ const points = [start, ...candidate, end];
447
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
448
+ && routeClearsComponents(conn, points)
449
+ && routeMeetsCompositionFloors(points)
450
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
451
+ }
452
+ }
453
+
454
+ const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
455
+ const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
456
+ if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
457
+ const outsideChannels = [
458
+ Math.max(start[1], end[1]) + minimumStub * 2,
459
+ Math.min(start[1], end[1]) - minimumStub * 2,
460
+ ];
461
+ for (const channelY of outsideChannels) {
462
+ const candidate = [[start[0], channelY], [end[0], channelY]];
463
+ const points = [start, ...candidate, end];
464
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
465
+ && routeClearsComponents(conn, points)
466
+ && routeMeetsCompositionFloors(points)
467
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)) return candidate;
468
+ }
469
+ }
470
+
471
+ const midX = (start[0] + end[0]) / 2;
472
+ const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
473
+ const midY = (start[1] + end[1]) / 2;
474
+ const verticalFirst = [[start[0], midY], [end[0], midY]];
475
+ const candidates = [horizontalFirst, verticalFirst];
476
+ const sideSafe = candidates.filter((candidate) => (
477
+ routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
478
+ ));
479
+ const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
480
+ const nearParallelPorts = (
481
+ ((fromSide === 'top' || fromSide === 'bottom')
482
+ && (toSide === 'top' || toSide === 'bottom')
483
+ && deltaX < minimumStub * 2)
484
+ || ((fromSide === 'left' || fromSide === 'right')
485
+ && (toSide === 'left' || toSide === 'right')
486
+ && deltaY < minimumStub * 2)
487
+ );
488
+ const ordered = [
489
+ ...(nearParallelPorts ? sideAware : sideSafe),
490
+ ...(nearParallelPorts ? sideSafe : sideAware),
491
+ ];
492
+ for (const candidate of ordered) {
493
+ const points = [start, ...candidate, end];
494
+ if (routeClearsEndpointComponents(points, from, to)
495
+ && routeClearsComponents(conn, points)
496
+ && routeMeetsCompositionFloors(points)
497
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)
498
+ && (!distinctAutomaticPorts || (!routeOverlapsResolved(conn, points, resolvedRoutes)
499
+ && !siblingCrossings(conn, points, resolvedRoutes)))) return candidate;
500
+ }
501
+ // Siblings fanning out from one side all want the same midpoint
502
+ // channel. Step outward through the corridor to a free parallel
503
+ // channel before searching.
504
+ if (distinctAutomaticPorts) {
505
+ const channels = (from, to, mid) => {
506
+ const [low, high] = [Math.min(from, to) + 24, Math.max(from, to) - 24];
507
+ const values = [];
508
+ for (let offset = 16; mid - offset >= low || mid + offset <= high; offset += 16) {
509
+ values.push(...[mid + offset, mid - offset].filter((value) => value >= low && value <= high));
510
+ }
511
+ return values;
512
+ };
513
+ for (const candidate of [
514
+ ...channels(start[0], end[0], midX).map((x) => [[x, start[1]], [x, end[1]]]),
515
+ ...channels(start[1], end[1], midY).map((y) => [[start[0], y], [end[0], y]]),
516
+ ]) {
517
+ const points = [start, ...candidate, end];
518
+ if (routeHonorsEndpointSides(points, fromSide, toSide)
519
+ && routeClearsEndpointComponents(points, from, to)
520
+ && routeClearsComponents(conn, points)
521
+ && routeMeetsCompositionFloors(points)
522
+ && !routeConflictsWithResolved(conn, points, resolvedRoutes)
523
+ && !routeOverlapsResolved(conn, points, resolvedRoutes)
524
+ && !siblingCrossings(conn, points, resolvedRoutes)) return candidate;
525
+ }
526
+ }
527
+
528
+ // Two-bend doglegs are deliberately cheap, but a real architecture can
529
+ // place adjacent components on both of those corridors. Search a
530
+ // bounded obstacle grid before falling back to a route that the Clean
531
+ // Flow gate already knows violates the inferred endpoint directions.
532
+ // This keeps ordinary multi-bend avoidance renderer-owned instead of
533
+ // forcing the author to hand-place via points.
534
+ // Alternative endpoint pairs are cheap probes, not another grid-search
535
+ // budget. A legal short bridge often beats the first grid detour.
536
+ if (!allowGridSearch) return sideSafe[0] || sideAware[0] || horizontalFirst;
537
+ if (planningMetrics.gridSearchCount >= planningMetrics.maximumGridSearchCount) {
538
+ planningMetrics.gridBudgetExhaustedCount += 1;
539
+ planningMetrics.conflictFallbackCount += 1;
540
+ return sideSafe[0] || sideAware[0] || horizontalFirst;
541
+ }
542
+ // First-draft architecture graphs are not always planar at their
543
+ // authored node positions. The renderer gives automatic crossings a
544
+ // visible halo, so the grid owns opaque-node avoidance and rejects
545
+ // ambiguous shared corridors without forcing the model to hand-route
546
+ // a sprawling perimeter detour. Cheap candidates above still prefer a
547
+ // genuinely crossing-free path whenever one is available.
548
+ const avoidedSegments = resolvedRoutes
549
+ .filter((entry) => distinctAutomaticPorts && relationshipsShareEndpoint(conn, entry.conn)
550
+ && !hasAuthoredRouteGeometry(entry.conn) && !entry.conn.labelAt && !conn.labelAt)
551
+ .flatMap((entry) => entry.points.slice(1).map((end, index) => ({
552
+ start: entry.points[index], end,
553
+ })));
554
+ const gridMetrics = {};
555
+ planningMetrics.gridSearchCount += 1;
556
+ planningMetrics.avoidedSegmentCount += avoidedSegments.length;
557
+ const searched = shortestOrthogonalGridRoute({
558
+ start,
559
+ end,
560
+ points: [start, end],
561
+ obstacles: [...components.values(), ...reservedLabelObstacles(2)],
562
+ fromSide,
563
+ toSide,
564
+ clearance: 2,
565
+ maximumObstacleCount: 80,
566
+ endpointStubPx: 24,
567
+ maximumGridNodes: 4096,
568
+ avoidedSegments,
569
+ allowAvoidedCrossings: true,
570
+ minimumAvoidedOverlapPx: 8,
571
+ routeSeparationPx: 8,
572
+ minimumSegmentPx: interiorSegmentPx,
573
+ borderSegments: frameBorders,
574
+ bendPenaltyPx: 48,
575
+ metrics: gridMetrics,
576
+ });
577
+ planningMetrics.gridCandidateNodeCount += gridMetrics.candidateNodeCount || 0;
578
+ planningMetrics.gridUsableNodeCount += gridMetrics.usableNodeCount || 0;
579
+ planningMetrics.gridEdgeCount += gridMetrics.graphEdgeCount || 0;
580
+ planningMetrics.gridVisitedNodeCount += gridMetrics.visitedNodeCount || 0;
581
+ const clearsEndpoints = searched
582
+ ? routeClearsEndpointComponents(searched.points, from, to) : false;
583
+ const clearsComponents = searched
584
+ ? routeClearsComponents(conn, searched.points) : false;
585
+ const clearsRelationships = searched
586
+ ? !routeConflictsWithResolved(conn, searched.points, resolvedRoutes) : false;
587
+ const clearsSharedCorridors = searched
588
+ ? !routeOverlapsResolved(conn, searched.points, resolvedRoutes) : false;
589
+ const meetsFloors = searched ? routeMeetsCompositionFloors(searched.points) : false;
590
+ const accepted = Boolean(
591
+ searched && clearsEndpoints && clearsComponents && clearsSharedCorridors && meetsFloors,
592
+ );
593
+ planningMetrics.gridAttempts.push({
594
+ relationship: conn.id || `${conn.from}->${conn.to}`,
595
+ fromSide,
596
+ toSide,
597
+ inputAvoidedSegmentCount: avoidedSegments.length,
598
+ ...gridMetrics,
599
+ accepted,
600
+ ...(!accepted && searched ? {
601
+ rejectedBy: [
602
+ ...(!clearsEndpoints ? ['endpoint-components'] : []),
603
+ ...(!clearsComponents ? ['components'] : []),
604
+ ...(!clearsSharedCorridors ? ['shared-corridor'] : []),
605
+ ...(!meetsFloors ? ['composition-floors'] : []),
606
+ ],
607
+ candidatePoints: searched.points,
608
+ } : {}),
609
+ });
610
+ if (accepted) {
611
+ planningMetrics.gridRoutedCount += 1;
612
+ if (!clearsRelationships) planningMetrics.crossoverRoutedCount += 1;
613
+ return searched.points.slice(1, -1);
614
+ }
615
+
616
+ // Both bounded doglegs are blocked. Keep the best endpoint-safe route
617
+ // when one exists so the universal Clean Flow gate reports the actual
618
+ // obstacle; otherwise preserve the historical deterministic fallback
619
+ // and let the endpoint-direction gate explain the side mismatch.
620
+ planningMetrics.conflictFallbackCount += 1;
621
+ return sideSafe[0] || sideAware[0] || horizontalFirst;
622
+ }
623
+ }
624
+ }
625
+
626
+ // A node's neighbours laid out as one row below (or above) it read as a
627
+ // fan-out: reach them all through the same vertical side. Center-based
628
+ // inference alone sends the outer ones sideways around their siblings.
629
+ const neighbourRects = new Map();
630
+ for (const conn of connections) {
631
+ const from = components.get(conn.from);
632
+ const to = components.get(conn.to);
633
+ if (!from || !to || from === to) continue;
634
+ for (const [node, other] of [[from, to], [to, from]]) {
635
+ neighbourRects.set(node.id, [...(neighbourRects.get(node.id) || []), other]);
636
+ }
637
+ }
638
+ function rowFanOutSides(from, to) {
639
+ if (!preferReadableRoutes) return null;
640
+ const verticalSides = (node, other) => {
641
+ const below = other.y >= node.y + node.height;
642
+ if (!below && other.y + other.height > node.y) return null;
643
+ if (['top', 'bottom'].includes(defaultFromSide(node, other))) return null;
644
+ const rowSibling = (neighbourRects.get(node.id) || []).some((sibling) => sibling !== other
645
+ && Math.abs(sibling.cy - other.cy) < 1
646
+ && defaultFromSide(node, sibling) === (below ? 'bottom' : 'top'));
647
+ // Only when a node of that row blocks a sideways route to one of the
648
+ // neighbours on this side; otherwise side exits stay clear and keep the
649
+ // vertical side free. Decide per side so a row never mixes both styles.
650
+ const blocked = (target) => {
651
+ const [gapStart, gapEnd] = target.cx < node.cx
652
+ ? [target.x + target.width, node.x] : [node.x + node.width, target.x];
653
+ return [...components.values()].some((rect) => rect !== node && rect !== target
654
+ && rect.y < target.y + target.height && rect.y + rect.height > target.y
655
+ && rect.x < gapEnd && rect.x + rect.width > gapStart);
656
+ };
657
+ const sameSide = (neighbourRects.get(node.id) || []).filter((sibling) => Math.abs(sibling.cy - other.cy) < 1
658
+ && defaultFromSide(node, sibling) === defaultFromSide(node, other));
659
+ return rowSibling && sameSide.some(blocked) ? (below ? 'bottom' : 'top') : null;
660
+ };
661
+ const opposite = { top: 'bottom', bottom: 'top' };
662
+ const fromSide = verticalSides(from, to);
663
+ if (fromSide) return { fromSide, toSide: opposite[fromSide] };
664
+ const toSide = verticalSides(to, from);
665
+ return toSide ? { fromSide: opposite[toSide], toSide } : null;
666
+ }
667
+
668
+ const pathCache = new Map();
669
+ const selectedSides = new Map();
670
+ const stroke = (relation) => relation.width || (relation.variant === 'emphasis' ? 1.8 : 1.5);
671
+ const markerSpacing = (left, right) => 3.5 * (stroke(left) + stroke(right));
672
+ const portSpacing = (left, right) => Math.max(14, markerSpacing(left, right) + 3.5);
673
+ const automaticPorts = automaticPortSpread(connections, components, {
674
+ sideFor: (relation, endpoint) => rowFanOutSides(components.get(relation.from), components.get(relation.to))
675
+ ?.[endpoint === 'source' ? 'fromSide' : 'toSide'],
676
+ // Preserve the established initial placement for ordinary markers; only
677
+ // widen groups whose arrowheads cannot fit the legacy 14px slots.
678
+ ...(distinctAutomaticPorts ? { spacingFor: (left, right) =>
679
+ markerSpacing(left, right) > 14 ? portSpacing(left, right) : 14 } : {}),
680
+ });
681
+ const incidentEndpoints = new Map();
682
+ for (const conn of connections) {
683
+ if (!components.has(conn.from) || !components.has(conn.to)) continue;
684
+ for (const [field, sideField] of [['from', 'fromSide'], ['to', 'toSide']]) {
685
+ const entries = incidentEndpoints.get(conn[field]) || [];
686
+ entries.push({ conn, field, sideField });
687
+ incidentEndpoints.set(conn[field], entries);
688
+ }
689
+ }
690
+ function inferredConnectionSides(conn) {
691
+ const from = components.get(conn.from);
692
+ const to = components.get(conn.to);
693
+ const fanOut = rowFanOutSides(from, to);
694
+ return {
695
+ fromSide: chosenSide(conn.fromSide, fanOut?.fromSide || defaultFromSide(from, to)),
696
+ toSide: chosenSide(conn.toSide, fanOut?.toSide || defaultToSide(from, to)),
697
+ };
698
+ }
699
+
700
+ function connectionSides(conn) {
701
+ if (!routesPlanned && !routesPlanning) planRoutes();
702
+ return selectedSides.get(conn) || inferredConnectionSides(conn);
703
+ }
704
+
705
+ function connectionEndpointSide(conn, endpoint) {
706
+ const field = endpoint === 'source' ? 'fromSide' : 'toSide';
707
+ if (conn[field] && conn[field] !== 'auto') return conn[field];
708
+ return connectionSides(conn)[field];
709
+ }
710
+
711
+ function hasAuthoredRouteGeometry(conn) {
712
+ return Boolean(
713
+ conn?.via
714
+ || (conn?.route && conn.route !== 'auto')
715
+ || conn?.channelX !== undefined
716
+ || conn?.channelY !== undefined
717
+ );
718
+ }
719
+
720
+ function hasAuthoredLabelPlacement(conn) {
721
+ return ['labelAt', 'labelDx', 'labelDy', 'labelSegment']
722
+ .some((field) => conn?.[field] !== undefined);
723
+ }
724
+
725
+ // A route can change sides after the initial port spread. Reserve slots on
726
+ // the final side as well: falling back to its midpoint can put an arrow
727
+ // between two existing slots, or directly on another incoming arrow.
728
+ function automaticEndpoint(conn, endpoint, rect, side, inferredSide) {
729
+ const initial = side === inferredSide ? automaticPorts.get(conn)?.[endpoint] : null;
730
+ const preferred = initial || anchor(rect, side);
731
+ if (hasAuthoredRouteGeometry(conn) || conn.labelAt) return { point: preferred, spread: Boolean(initial) };
732
+ const axis = side === 'left' || side === 'right' ? 1 : 0;
733
+ const occupied = [];
734
+ for (const { conn: other, field, sideField } of incidentEndpoints.get(rect.id) || []) {
735
+ if (other === conn || hasAuthoredRouteGeometry(other) || other.labelAt) continue;
736
+ const routed = pathCache.get(other);
737
+ const sides = selectedSides.get(other) || inferredConnectionSides(other);
738
+ if (sides[sideField] !== side) continue;
739
+ const point = routed
740
+ ? (field === 'from' ? routed.points[0] : routed.points.at(-1))
741
+ : automaticPorts.get(other)?.[field] || anchor(rect, side);
742
+ occupied.push({ value: point[axis], spacing: portSpacing(conn, other) });
743
+ }
744
+ if (!occupied.length) return { point: preferred, spread: Boolean(initial) };
745
+ const candidates = [preferred[axis], ...occupied.flatMap(({ value, spacing }) => [value - spacing, value + spacing])]
746
+ .sort((a, b) => Math.abs(a - preferred[axis]) - Math.abs(b - preferred[axis]) || a - b);
747
+ for (const value of candidates) {
748
+ const point = [...preferred];
749
+ point[axis] = value;
750
+ if (portHasCornerClearance(rect, side, point)
751
+ && occupied.every((other) => Math.abs(value - other.value) >= other.spacing - 0.0001)) {
752
+ return { point, spread: true };
753
+ }
754
+ }
755
+ return { point: preferred, spread: true, crowded: true };
756
+ }
757
+
758
+ function connectionGeometry(conn, sides = inferredConnectionSides(conn)) {
759
+ const from = components.get(conn.from);
760
+ const to = components.get(conn.to);
761
+ const inferred = inferredConnectionSides(conn);
762
+ const { fromSide, toSide } = sides;
763
+ const legacyPorts = fromSide === inferred.fromSide && toSide === inferred.toSide ? automaticPorts.get(conn) : null;
764
+ const source = distinctAutomaticPorts
765
+ ? automaticEndpoint(conn, 'from', from, fromSide, inferred.fromSide)
766
+ : { point: legacyPorts?.from || anchor(from, fromSide), spread: Boolean(legacyPorts?.from) };
767
+ const target = distinctAutomaticPorts
768
+ ? automaticEndpoint(conn, 'to', to, toSide, inferred.toSide)
769
+ : { point: legacyPorts?.to || anchor(to, toSide), spread: Boolean(legacyPorts?.to) };
770
+ const ports = { from: source.spread, to: target.spread };
771
+ const { start, end } = alignFacingPorts(
772
+ conn,
773
+ from,
774
+ to,
775
+ source.point,
776
+ target.point,
777
+ fromSide,
778
+ toSide,
779
+ ports,
780
+ );
781
+ return { from, to, start, end, fromSide, toSide, crowded: source.crowded || target.crowded };
782
+ }
783
+
784
+ function routedForGeometry(conn, resolvedRoutes, geometry) {
785
+ const { from, to, start, end, fromSide, toSide } = geometry;
786
+ const authoredPoints = [
787
+ start,
788
+ ...routeVia(conn, from, to, start, end, fromSide, toSide, resolvedRoutes),
789
+ end,
790
+ ];
791
+ // Explicit waypoints are author-owned geometry. Keep even a collinear
792
+ // waypoint: it can intentionally split a route at a semantic touch point,
793
+ // and preserving it is part of the backwards-compatible authoring contract.
794
+ // Automatic routes remain normalized so the renderer does not emit noisy
795
+ // duplicate turns or zero-length segments.
796
+ const points = hasAuthoredRouteGeometry(conn)
797
+ ? authoredPoints
798
+ : normalizeRoutePoints(authoredPoints);
799
+ return { d: roundedPath(points, 8), points };
800
+ }
801
+
802
+ function cachePath(conn, routed, sides) {
803
+ pathCache.set(conn, routed);
804
+ selectedSides.set(conn, { fromSide: sides.fromSide, toSide: sides.toSide });
805
+ return routed;
806
+ }
807
+
808
+ const SIDE_ORDER = ['right', 'bottom', 'left', 'top'];
809
+ function candidateSidePairs(conn) {
810
+ const inferred = inferredConnectionSides(conn);
811
+ const authoredFrom = conn.fromSide && conn.fromSide !== 'auto' ? conn.fromSide : null;
812
+ const authoredTo = conn.toSide && conn.toSide !== 'auto' ? conn.toSide : null;
813
+ const fromOptions = authoredFrom
814
+ ? [authoredFrom]
815
+ : [inferred.fromSide, ...SIDE_ORDER.filter((side) => side !== inferred.fromSide)];
816
+ const toOptions = authoredTo
817
+ ? [authoredTo]
818
+ : [inferred.toSide, ...SIDE_ORDER.filter((side) => side !== inferred.toSide)];
819
+ return fromOptions.flatMap((fromSide) => toOptions.map((toSide) => ({
820
+ fromSide,
821
+ toSide,
822
+ deviationCount: Number(fromSide !== inferred.fromSide) + Number(toSide !== inferred.toSide),
823
+ }))).sort((left, right) => {
824
+ if (left.deviationCount !== right.deviationCount) {
825
+ return left.deviationCount - right.deviationCount;
826
+ }
827
+ const leftGeometry = connectionGeometry(conn, left);
828
+ const rightGeometry = connectionGeometry(conn, right);
829
+ const leftDistance = Math.abs(leftGeometry.end[0] - leftGeometry.start[0])
830
+ + Math.abs(leftGeometry.end[1] - leftGeometry.start[1]);
831
+ const rightDistance = Math.abs(rightGeometry.end[0] - rightGeometry.start[0])
832
+ + Math.abs(rightGeometry.end[1] - rightGeometry.start[1]);
833
+ return leftDistance - rightDistance;
834
+ });
835
+ }
836
+
837
+ function routeIsClear(conn, routed, geometry, resolvedRoutes) {
838
+ return !geometry.crowded && routed.points.length >= 2
839
+ && routeHonorsEndpointSides(routed.points, geometry.fromSide, geometry.toSide)
840
+ && routeClearsEndpointComponents(routed.points, geometry.from, geometry.to)
841
+ && routeClearsComponents(conn, routed.points)
842
+ && routeMeetsCompositionFloors(routed.points)
843
+ && !routeOverlapsResolved(conn, routed.points, resolvedRoutes);
844
+ }
845
+
846
+ function crossingCount(conn, points, resolvedRoutes) {
847
+ return resolvedRoutes.reduce((total, entry) => total + Number(
848
+ !(relationshipsShareEndpoint(conn, entry.conn)
849
+ && (!distinctAutomaticPorts || hasAuthoredRouteGeometry(entry.conn) || entry.conn.labelAt || conn.labelAt))
850
+ && points.slice(1).some((end, index) => entry.points.slice(1).some((otherEnd, otherIndex) =>
851
+ properSegmentIntersection(points[index], end, entry.points[otherIndex], otherEnd))),
852
+ ), 0);
853
+ }
854
+
855
+ function readabilityCost(conn, routed, resolvedRoutes) {
856
+ const points = routed.points;
857
+ const length = points.slice(1).reduce((total, point, index) => total
858
+ + Math.abs(point[0] - points[index][0]) + Math.abs(point[1] - points[index][1]), 0);
859
+ // A crossover is a reading cost even with a halo. Keep it finite: avoiding
860
+ // one crossing must not justify an arbitrarily long perimeter excursion.
861
+ return length + Math.max(0, points.length - 2) * 48 + crossingCount(conn, points, resolvedRoutes) * 160;
862
+ }
863
+
864
+ function computePath(conn, resolvedRoutes) {
865
+ if (hasAuthoredRouteGeometry(conn)) {
866
+ const sides = inferredConnectionSides(conn);
867
+ return cachePath(conn, routedForGeometry(
868
+ conn,
869
+ resolvedRoutes,
870
+ connectionGeometry(conn, sides),
871
+ ), sides);
872
+ }
873
+
874
+ let firstFallback = null;
875
+ const clearRoute = () => {
876
+ let firstClear = null;
877
+ for (const sides of candidateSidePairs(conn)) {
878
+ const geometry = connectionGeometry(conn, sides);
879
+ const routed = routedForGeometry(conn, resolvedRoutes, geometry);
880
+ if (!firstFallback) firstFallback = { routed, sides };
881
+ if (routeIsClear(conn, routed, geometry, resolvedRoutes)) {
882
+ firstClear = { routed, sides };
883
+ break;
884
+ }
885
+ }
886
+ return firstClear;
887
+ };
888
+ const routeLength = ({ routed }) => routed.points.slice(1)
889
+ .reduce((total, point, index) => total + Math.abs(point[0] - routed.points[index][0]) + Math.abs(point[1] - routed.points[index][1]), 0);
890
+
891
+ // Reserved labels are a preference, not an obstacle. A route that has no
892
+ // corridor around them, or that would have to detour far around them,
893
+ // takes the plain route instead and the label placement pass moves the
894
+ // label; only a modest extra length is worth keeping a label in place.
895
+ honourReservedLabels = true;
896
+ let chosen = clearRoute();
897
+ if (reservedLabels.length) {
898
+ honourReservedLabels = false;
899
+ if (!chosen) chosen = clearRoute();
900
+ else {
901
+ const direct = Math.abs(chosen.routed.points.at(-1)[0] - chosen.routed.points[0][0])
902
+ + Math.abs(chosen.routed.points.at(-1)[1] - chosen.routed.points[0][1]);
903
+ if (routeLength(chosen) > direct * 1.25 + 64) {
904
+ const plain = clearRoute();
905
+ if (plain && routeLength(plain) * 1.25 + 64 < routeLength(chosen)) chosen = plain;
906
+ }
907
+ }
908
+ honourReservedLabels = true;
909
+ }
910
+ if (chosen) return cachePath(conn, chosen.routed, chosen.sides);
911
+ return cachePath(conn, firstFallback.routed, firstFallback.sides);
912
+ }
913
+
914
+ let routesPlanned = false;
915
+ let routesPlanning = false;
916
+ function planRoutes() {
917
+ if (routesPlanned || routesPlanning) return;
918
+ routesPlanning = true;
919
+ const indexed = connections
920
+ .map((conn, index) => ({ conn, index }))
921
+ .filter(({ conn }) => components.has(conn.from) && components.has(conn.to));
922
+ const explicit = indexed.filter(({ conn }) => hasAuthoredRouteGeometry(conn));
923
+ const automatic = indexed.filter(({ conn }) => !hasAuthoredRouteGeometry(conn))
924
+ .sort((left, right) => {
925
+ const leftFrom = components.get(left.conn.from);
926
+ const leftTo = components.get(left.conn.to);
927
+ const rightFrom = components.get(right.conn.from);
928
+ const rightTo = components.get(right.conn.to);
929
+ const leftDistance = Math.abs(leftTo.cx - leftFrom.cx) + Math.abs(leftTo.cy - leftFrom.cy);
930
+ const rightDistance = Math.abs(rightTo.cx - rightFrom.cx) + Math.abs(rightTo.cy - rightFrom.cy);
931
+ return leftDistance - rightDistance || left.index - right.index;
932
+ });
933
+ const resolvedRoutes = [];
934
+ reservedLabels = [];
935
+ for (const { conn } of [...explicit, ...automatic]) {
936
+ const routed = computePath(conn, resolvedRoutes);
937
+ resolvedRoutes.push({ conn, points: routed.points });
938
+ const rect = labelRectFor?.(conn, routed.points, {
939
+ routes: resolvedRoutes.map((entry) => entry.points),
940
+ labels: reservedLabels.map((entry) => entry.rect),
941
+ });
942
+ if (rect) reservedLabels.push({ conn, rect });
943
+ }
944
+ // Improve against the complete set of routes, not only earlier edges.
945
+ // A greedy side change during the initial pass can steal a later edge's
946
+ // corridor. This bounded sweep only changes one route at a time, keeping
947
+ // every other route and label as an obstacle/reference.
948
+ if (preferReadableRoutes) {
949
+ const scenePoints = resolvedRoutes.flatMap((entry) => entry.points);
950
+ for (const rect of [...components.values(), ...frames, ...reservedLabels.map((entry) => entry.rect)]) {
951
+ scenePoints.push([rect.x, rect.y], [rect.x + rect.width, rect.y + rect.height]);
952
+ }
953
+ const bounds = {
954
+ left: Math.min(...scenePoints.map(([x]) => x)), right: Math.max(...scenePoints.map(([x]) => x)),
955
+ top: Math.min(...scenePoints.map(([, y]) => y)), bottom: Math.max(...scenePoints.map(([, y]) => y)),
956
+ };
957
+ const withinScene = ([x, y]) => x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom;
958
+ const jointlyImproved = new Set();
959
+ function improveReciprocalPairs() {
960
+ // A one-route sweep cannot repair reciprocal facing edges whose initial
961
+ // spread groups differ. Try both direct lanes together after final side
962
+ // geometry is known, leaving every other route and authored port in place.
963
+ const paired = new Set();
964
+ for (const first of resolvedRoutes) {
965
+ const conn = first.conn;
966
+ if (paired.has(conn) || hasAuthoredRouteGeometry(conn) || hasAuthoredLabelPlacement(conn)
967
+ || (conn.fromSide && conn.fromSide !== 'auto')
968
+ || (conn.toSide && conn.toSide !== 'auto')) continue;
969
+ const second = resolvedRoutes.find((entry) => entry !== first
970
+ && entry.conn.from === conn.to && entry.conn.to === conn.from
971
+ && !hasAuthoredRouteGeometry(entry.conn) && !hasAuthoredLabelPlacement(entry.conn)
972
+ && (!entry.conn.fromSide || entry.conn.fromSide === 'auto')
973
+ && (!entry.conn.toSide || entry.conn.toSide === 'auto'));
974
+ if (!second) continue;
975
+ paired.add(conn);
976
+ paired.add(second.conn);
977
+ const firstSides = inferredConnectionSides(conn);
978
+ const secondSides = inferredConnectionSides(second.conn);
979
+ const horizontal = firstSides.fromSide === 'right' && firstSides.toSide === 'left'
980
+ && secondSides.fromSide === 'left' && secondSides.toSide === 'right';
981
+ const horizontalReverse = firstSides.fromSide === 'left' && firstSides.toSide === 'right'
982
+ && secondSides.fromSide === 'right' && secondSides.toSide === 'left';
983
+ const vertical = firstSides.fromSide === 'bottom' && firstSides.toSide === 'top'
984
+ && secondSides.fromSide === 'top' && secondSides.toSide === 'bottom';
985
+ const verticalReverse = firstSides.fromSide === 'top' && firstSides.toSide === 'bottom'
986
+ && secondSides.fromSide === 'bottom' && secondSides.toSide === 'top';
987
+ if (!horizontal && !horizontalReverse && !vertical && !verticalReverse) continue;
988
+ if (first.points.length <= 2 && second.points.length <= 2) continue;
989
+ const axis = horizontal || horizontalReverse ? 1 : 0;
990
+ const others = resolvedRoutes.filter((entry) => entry !== first && entry !== second);
991
+ const firstGeometry = connectionGeometry(conn, firstSides);
992
+ const secondGeometry = connectionGeometry(second.conn, secondSides);
993
+ if (firstGeometry.crowded || secondGeometry.crowded) continue;
994
+ const previousLabels = reservedLabels;
995
+ let improved = false;
996
+ reservedLabels = reservedLabels.filter((entry) => entry.conn !== conn && entry.conn !== second.conn);
997
+ try {
998
+ const lanes = (geometry) => [...new Set([geometry.start[axis], geometry.end[axis]])];
999
+ const direct = (geometry, lane) => {
1000
+ const start = [...geometry.start];
1001
+ const end = [...geometry.end];
1002
+ start[axis] = lane;
1003
+ end[axis] = lane;
1004
+ return { points: [start, end], d: roundedPath([start, end], 8) };
1005
+ };
1006
+ const endpointSlotsClear = (candidateRoutes) => {
1007
+ const entries = [...others, ...candidateRoutes];
1008
+ for (const candidate of candidateRoutes) {
1009
+ const sides = candidate.conn === conn ? firstSides : secondSides;
1010
+ for (const [componentId, side, point] of [
1011
+ [candidate.conn.from, sides.fromSide, candidate.points[0]],
1012
+ [candidate.conn.to, sides.toSide, candidate.points.at(-1)],
1013
+ ]) {
1014
+ const coordinate = side === 'left' || side === 'right' ? 1 : 0;
1015
+ for (const other of entries) {
1016
+ if (other.conn === candidate.conn) continue;
1017
+ const otherSides = other.conn === conn ? firstSides
1018
+ : other.conn === second.conn ? secondSides : selectedSides.get(other.conn);
1019
+ for (const [otherId, otherSide, otherPoint] of [
1020
+ [other.conn.from, otherSides.fromSide, other.points[0]],
1021
+ [other.conn.to, otherSides.toSide, other.points.at(-1)],
1022
+ ]) {
1023
+ if (componentId === otherId && side === otherSide
1024
+ && Math.abs(point[coordinate] - otherPoint[coordinate])
1025
+ < portSpacing(candidate.conn, other.conn) - 0.0001) return false;
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ return true;
1031
+ };
1032
+ const labelClears = (rect, owner, entries, labels) => {
1033
+ if (!rect) return !owner.label;
1034
+ if (!withinScene([rect.x, rect.y])
1035
+ || !withinScene([rect.x + rect.width, rect.y + rect.height])
1036
+ || labels.some((other) => rectsOverlap(rect, other.rect, 2))) return false;
1037
+ return entries.every((entry) => entry.conn === owner || entry.points.slice(1).every((end, index) =>
1038
+ !segmentIntersectsRect({ start: entry.points[index], end }, rect, LABEL_CLEARANCE)));
1039
+ };
1040
+ let best = null;
1041
+ let bestCost = readabilityCost(conn, first, [...others, second])
1042
+ + readabilityCost(second.conn, second, [...others, first]);
1043
+ for (const firstLane of lanes(firstGeometry)) {
1044
+ for (const secondLane of lanes(secondGeometry)) {
1045
+ planningMetrics.reciprocalCandidateCount += 1;
1046
+ const firstRoute = { conn, ...direct(firstGeometry, firstLane) };
1047
+ const secondRoute = { conn: second.conn, ...direct(secondGeometry, secondLane) };
1048
+ const candidates = [firstRoute, secondRoute];
1049
+ if (!candidates.every((entry) => entry.points.every(withinScene))) continue;
1050
+ if (!endpointSlotsClear(candidates)) continue;
1051
+ if (!candidates.every((entry, index) => {
1052
+ const geometry = entry.conn === conn ? firstGeometry : secondGeometry;
1053
+ if (!portHasCornerClearance(geometry.from, geometry.fromSide, entry.points[0])
1054
+ || !portHasCornerClearance(geometry.to, geometry.toSide, entry.points.at(-1))) return false;
1055
+ return routeIsClear(entry.conn, entry, geometry, [...others, candidates[1 - index]]);
1056
+ })) continue;
1057
+ const firstRect = labelRectFor?.(conn, firstRoute.points, {
1058
+ routes: [...others.map((entry) => entry.points), ...candidates.map((entry) => entry.points)],
1059
+ labels: reservedLabels.map((entry) => entry.rect),
1060
+ });
1061
+ if (!labelClears(firstRect, conn, [...others, secondRoute], reservedLabels)) continue;
1062
+ const secondRect = labelRectFor?.(second.conn, secondRoute.points, {
1063
+ routes: [...others.map((entry) => entry.points), ...candidates.map((entry) => entry.points)],
1064
+ labels: [...reservedLabels.map((entry) => entry.rect), ...(firstRect ? [firstRect] : [])],
1065
+ });
1066
+ if (!labelClears(secondRect, second.conn, [...others, firstRoute], [
1067
+ ...reservedLabels, ...(firstRect ? [{ conn, rect: firstRect }] : []),
1068
+ ])) continue;
1069
+ const cost = readabilityCost(conn, firstRoute, [...others, secondRoute])
1070
+ + readabilityCost(second.conn, secondRoute, [...others, firstRoute]);
1071
+ if (cost < bestCost) {
1072
+ best = { firstRoute, secondRoute, firstRect, secondRect };
1073
+ bestCost = cost;
1074
+ }
1075
+ }
1076
+ }
1077
+ if (!best) continue;
1078
+ cachePath(conn, best.firstRoute, firstSides);
1079
+ cachePath(second.conn, best.secondRoute, secondSides);
1080
+ first.points = best.firstRoute.points;
1081
+ second.points = best.secondRoute.points;
1082
+ planningMetrics.reciprocalImprovedCount += 1;
1083
+ jointlyImproved.add(conn);
1084
+ jointlyImproved.add(second.conn);
1085
+ improved = true;
1086
+ reservedLabels = [
1087
+ ...reservedLabels,
1088
+ ...(best.firstRect ? [{ conn, rect: best.firstRect }] : []),
1089
+ ...(best.secondRect ? [{ conn: second.conn, rect: best.secondRect }] : []),
1090
+ ];
1091
+ } finally {
1092
+ if (!improved) reservedLabels = previousLabels;
1093
+ }
1094
+ }
1095
+ }
1096
+ allowGridSearch = false;
1097
+ try {
1098
+ improveReciprocalPairs();
1099
+ for (const entry of resolvedRoutes) {
1100
+ const { conn } = entry;
1101
+ if (jointlyImproved.has(conn) || hasAuthoredRouteGeometry(conn) || conn.labelAt) continue;
1102
+ const others = resolvedRoutes.filter((other) => other !== entry);
1103
+ // Preserve uncomplicated routes and their established inferred sides.
1104
+ // Spend the comparison budget where the complete scene has a reading
1105
+ // cost: a crossover or more than two bends.
1106
+ if (entry.points.length <= 4 && crossingCount(conn, entry.points, others) === 0) continue;
1107
+ let best = null;
1108
+ let bestCost = readabilityCost(conn, { points: entry.points }, others);
1109
+ for (const sides of candidateSidePairs(conn)) {
1110
+ const geometry = connectionGeometry(conn, sides);
1111
+ const routed = routedForGeometry(conn, others, geometry);
1112
+ planningMetrics.readabilityCandidateCount += 1;
1113
+ if (!routed.points.every(withinScene) || !routeIsClear(conn, routed, geometry, others)) continue;
1114
+ const cost = readabilityCost(conn, routed, others);
1115
+ if (cost < bestCost) {
1116
+ const rect = labelRectFor?.(conn, routed.points, {
1117
+ routes: [...others.map((other) => other.points), routed.points],
1118
+ labels: reservedLabels.filter((label) => label.conn !== conn).map((label) => label.rect),
1119
+ });
1120
+ // A shorter route must not expand the canvas and shrink every
1121
+ // label at the default viewport. Include its reserved label too.
1122
+ if (rect && (!withinScene([rect.x, rect.y]) || !withinScene([rect.x + rect.width, rect.y + rect.height]))) continue;
1123
+ best = { routed, sides, rect };
1124
+ bestCost = cost;
1125
+ }
1126
+ }
1127
+ if (!best) continue;
1128
+ cachePath(conn, best.routed, best.sides);
1129
+ entry.points = best.routed.points;
1130
+ planningMetrics.readabilityImprovedCount += 1;
1131
+ reservedLabels = reservedLabels.filter((label) => label.conn !== conn);
1132
+ if (best.rect) reservedLabels.push({ conn, rect: best.rect });
1133
+ }
1134
+ } finally {
1135
+ allowGridSearch = true;
1136
+ }
1137
+ }
1138
+ planningMetrics.routeCount = resolvedRoutes.length;
1139
+ planningMetrics.explicitRouteCount = explicit.length;
1140
+ planningMetrics.automaticRouteCount = automatic.length;
1141
+ routesPlanning = false;
1142
+ routesPlanned = true;
1143
+ }
1144
+
1145
+ function pathFor(conn) {
1146
+ planRoutes();
1147
+ if (pathCache.has(conn)) return pathCache.get(conn);
1148
+ return computePath(conn, []);
1149
+ }
1150
+
1151
+ function routingMetrics() {
1152
+ planRoutes();
1153
+ return { ...planningMetrics };
1154
+ }
1155
+
1156
+ return { pathFor, connectionSides, connectionEndpointSide, routingMetrics };
1157
+ }