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,1941 @@
1
+ // Geometry helpers shared by all typed renderers. Every function here is
2
+ // pure; renderers own their layout tables and pass measured rects
3
+ // ({x, y, width, height, cx, cy}) in.
4
+
5
+ import { recordDiagnostic } from './diagnostics.mjs';
6
+
7
+ // In degraded mode (no ajv) a type-wrong top-level field reaches the renderer.
8
+ // Coerce non-arrays to [] so the module-level Maps build without throwing and
9
+ // the friendly validator checks (which run later) report the real problem.
10
+ export function asArray(value) {
11
+ return Array.isArray(value) ? value : [];
12
+ }
13
+
14
+ // A computed coordinate must be a finite number; NaN/undefined would silently
15
+ // write `<rect x="NaN">` into the output. Used by the validators as a backstop.
16
+ export function isFinitePoint(...coords) {
17
+ return coords.every((c) => Number.isFinite(c));
18
+ }
19
+
20
+ // Separation the layout solver guarantees and separation this check re-derives
21
+ // travel through different arithmetic, so a pair the solver placed at exactly
22
+ // its minimum can land a few ulps short of this comparison. In #583 the CLI's
23
+ // own receipt has the pair `793.6 + 160 + 8` read as 2**-43 past a5's left edge
24
+ // at 961.5999999999999 — one ulp at that magnitude. 0.0001 is the numeric
25
+ // tolerance the route and layout geometry in this file already compares with.
26
+ const NUMERIC_CLEARANCE_PX = 0.0001;
27
+
28
+ export function rectsOverlap(a, b, gap = 0) {
29
+ // Non-finite geometry means "unknown", not "overlapping". Every comparison
30
+ // below is false for NaN, so without this guard the negation reports a
31
+ // collision for every pair. Callers surface non-finite pos/size through their
32
+ // own diagnostic; reporting it again as an overlap buries that message under
33
+ // one bogus separation hint per pair.
34
+ if (!isFinitePoint(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height)) {
35
+ return false;
36
+ }
37
+ return !(
38
+ a.x + a.width + gap <= b.x + NUMERIC_CLEARANCE_PX ||
39
+ b.x + b.width + gap <= a.x + NUMERIC_CLEARANCE_PX ||
40
+ a.y + a.height + gap <= b.y + NUMERIC_CLEARANCE_PX ||
41
+ b.y + b.height + gap <= a.y + NUMERIC_CLEARANCE_PX
42
+ );
43
+ }
44
+
45
+ export function segmentIntersectsRect(segment, rect, gap = 0) {
46
+ const box = {
47
+ x1: rect.x - gap,
48
+ y1: rect.y - gap,
49
+ x2: rect.x + rect.width + gap,
50
+ y2: rect.y + rect.height + gap
51
+ };
52
+ const [a, b] = [segment.start, segment.end];
53
+ if (pointInBox(a, box) || pointInBox(b, box)) return true;
54
+ return (
55
+ segmentsIntersect(a, b, [box.x1, box.y1], [box.x2, box.y1]) ||
56
+ segmentsIntersect(a, b, [box.x2, box.y1], [box.x2, box.y2]) ||
57
+ segmentsIntersect(a, b, [box.x2, box.y2], [box.x1, box.y2]) ||
58
+ segmentsIntersect(a, b, [box.x1, box.y2], [box.x1, box.y1])
59
+ );
60
+ }
61
+
62
+ export function segmentRectClearance(segment, rect) {
63
+ if (!segment || !rect) return null;
64
+ const { start, end } = segment;
65
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
66
+ if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
67
+ if (rect.width < 0 || rect.height < 0) return null;
68
+ if (segmentIntersectsRect(segment, rect)) return 0;
69
+
70
+ const right = rect.x + rect.width;
71
+ const bottom = rect.y + rect.height;
72
+ let clearance = Math.min(pointRectDistance(start, rect), pointRectDistance(end, rect));
73
+ const corners = [rect.x, rect.y, right, rect.y, right, bottom, rect.x, bottom];
74
+ for (let index = 0; index < 8; index += 2) {
75
+ const distance = pointSegmentDistanceXY(corners[index], corners[index + 1], start, end);
76
+ if (distance < clearance) clearance = distance;
77
+ }
78
+ return clearance;
79
+ }
80
+
81
+ // The same clearance, but the caller already knows the threshold it compares
82
+ // against: when an axis gap alone proves the distance cannot be smaller, the
83
+ // exact distance is never computed and Infinity is returned.
84
+ export function segmentRectClearanceWithin(segment, rect, limit) {
85
+ if (!segment || !rect) return segmentRectClearance(segment, rect);
86
+ const { start, end } = segment;
87
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
88
+ if (!isFinitePoint(start[0], start[1], end[0], end[1], rect.x, rect.y, rect.width, rect.height)) return null;
89
+ if (rect.width < 0 || rect.height < 0) return null;
90
+ if (segmentIntersectsRect(segment, rect)) return 0;
91
+ const minX = Math.min(start[0], end[0]);
92
+ const maxX = Math.max(start[0], end[0]);
93
+ const minY = Math.min(start[1], end[1]);
94
+ const maxY = Math.max(start[1], end[1]);
95
+ const gapX = Math.max(rect.x - maxX, minX - (rect.x + rect.width));
96
+ const gapY = Math.max(rect.y - maxY, minY - (rect.y + rect.height));
97
+ if (Math.max(gapX, gapY) > limit) return Number.POSITIVE_INFINITY;
98
+ return segmentRectClearance(segment, rect);
99
+ }
100
+
101
+ export function segmentRectIntersectionLength(segment, rect) {
102
+ if (!segment || !rect) return null;
103
+ const { start, end } = segment;
104
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
105
+ if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
106
+ if (rect.width < 0 || rect.height < 0) return null;
107
+
108
+ const dx = end[0] - start[0];
109
+ const dy = end[1] - start[1];
110
+ const length = Math.hypot(dx, dy);
111
+ if (length <= 0.0000001) return 0;
112
+ const bounds = [
113
+ [-dx, start[0] - rect.x],
114
+ [dx, rect.x + rect.width - start[0]],
115
+ [-dy, start[1] - rect.y],
116
+ [dy, rect.y + rect.height - start[1]],
117
+ ];
118
+ let enter = 0;
119
+ let leave = 1;
120
+ for (const [direction, distance] of bounds) {
121
+ if (Math.abs(direction) <= 0.0000001) {
122
+ if (distance < -0.0000001) return 0;
123
+ continue;
124
+ }
125
+ const ratio = distance / direction;
126
+ if (direction < 0) enter = Math.max(enter, ratio);
127
+ else leave = Math.min(leave, ratio);
128
+ if (enter > leave + 0.0000001) return 0;
129
+ }
130
+ return length * Math.max(0, leave - enter);
131
+ }
132
+
133
+ const ROUTE_INDEX_CELL_SIZE = 128;
134
+ const ROUTE_INDEX_MAX_CELLS_PER_ROUTE = 256;
135
+
136
+ function routeBounds(route) {
137
+ let x1 = Infinity;
138
+ let y1 = Infinity;
139
+ let x2 = -Infinity;
140
+ let y2 = -Infinity;
141
+ for (const [x, y] of route.points) {
142
+ x1 = Math.min(x1, x);
143
+ y1 = Math.min(y1, y);
144
+ x2 = Math.max(x2, x);
145
+ y2 = Math.max(y2, y);
146
+ }
147
+ return { x1, y1, x2, y2 };
148
+ }
149
+
150
+ function createRouteCandidateIndex(routes) {
151
+ const cells = new Map();
152
+ const globalRouteIndexes = [];
153
+
154
+ routes.forEach((route, routeIndex) => {
155
+ const bounds = routeBounds(route);
156
+ const x1 = Math.floor(bounds.x1 / ROUTE_INDEX_CELL_SIZE);
157
+ const y1 = Math.floor(bounds.y1 / ROUTE_INDEX_CELL_SIZE);
158
+ const x2 = Math.floor(bounds.x2 / ROUTE_INDEX_CELL_SIZE);
159
+ const y2 = Math.floor(bounds.y2 / ROUTE_INDEX_CELL_SIZE);
160
+ if (![x1, y1, x2, y2].every(Number.isSafeInteger)) {
161
+ globalRouteIndexes.push(routeIndex);
162
+ return;
163
+ }
164
+ const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
165
+
166
+ // Very long routes are cheaper to test once per label than to duplicate
167
+ // across a large part of the index.
168
+ if (!Number.isSafeInteger(cellCount) || cellCount > ROUTE_INDEX_MAX_CELLS_PER_ROUTE) {
169
+ globalRouteIndexes.push(routeIndex);
170
+ return;
171
+ }
172
+ for (let y = y1; y <= y2; y += 1) {
173
+ for (let x = x1; x <= x2; x += 1) {
174
+ const key = `${x}:${y}`;
175
+ const bucket = cells.get(key);
176
+ if (bucket) bucket.push(routeIndex);
177
+ else cells.set(key, [routeIndex]);
178
+ }
179
+ }
180
+ });
181
+
182
+ return function candidates(rect, threshold) {
183
+ const x1 = Math.floor((rect.x - threshold) / ROUTE_INDEX_CELL_SIZE);
184
+ const y1 = Math.floor((rect.y - threshold) / ROUTE_INDEX_CELL_SIZE);
185
+ const x2 = Math.floor((rect.x + rect.width + threshold) / ROUTE_INDEX_CELL_SIZE);
186
+ const y2 = Math.floor((rect.y + rect.height + threshold) / ROUTE_INDEX_CELL_SIZE);
187
+ const cellCount = (x2 - x1 + 1) * (y2 - y1 + 1);
188
+
189
+ // A huge threshold offers no selectivity and can overflow cell iteration.
190
+ if (![x1, y1, x2, y2].every(Number.isSafeInteger)
191
+ || !Number.isSafeInteger(cellCount)
192
+ || cellCount > Math.max(64, cells.size * 4)) return routes;
193
+
194
+ const routeIndexes = new Set(globalRouteIndexes);
195
+ for (let y = y1; y <= y2; y += 1) {
196
+ for (let x = x1; x <= x2; x += 1) {
197
+ for (const routeIndex of cells.get(`${x}:${y}`) || []) routeIndexes.add(routeIndex);
198
+ }
199
+ }
200
+ return [...routeIndexes]
201
+ .sort((left, right) => left - right)
202
+ .map((routeIndex) => routes[routeIndex]);
203
+ };
204
+ }
205
+
206
+ export function collectLabelRouteClearance({ labels, routedRelations, threshold }) {
207
+ if (!Number.isFinite(threshold) || threshold < 0) return [];
208
+ if (threshold === 0) return [];
209
+ const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => {
210
+ const relation = entry?.relation || entry;
211
+ const points = normalizeRoutePoints(entry?.points || relation?.routePoints);
212
+ if (!relation || points.length < 2) return null;
213
+ return {
214
+ relation,
215
+ relationIndex: Number.isInteger(entry?.relationIndex) ? entry.relationIndex : fallbackIndex,
216
+ points,
217
+ };
218
+ }).filter(Boolean);
219
+ const seenRoutes = new Set();
220
+ const routes = routeCandidates.filter((route) => {
221
+ const identity = relationshipIdentity(route.relation, route.relationIndex);
222
+ if (seenRoutes.has(identity)) return false;
223
+ seenRoutes.add(identity);
224
+ return true;
225
+ });
226
+ const hits = [];
227
+ const seenLabels = new Set();
228
+ const candidateRoutes = createRouteCandidateIndex(routes);
229
+
230
+ for (const [fallbackIndex, label] of asArray(labels).entries()) {
231
+ const rect = label?.rect || label;
232
+ if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height) || rect.width < 0 || rect.height < 0) continue;
233
+ const relationIndex = Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex;
234
+ const labelIdentity = relationshipIdentity(label?.relation, relationIndex);
235
+ if (seenLabels.has(labelIdentity)) continue;
236
+ seenLabels.add(labelIdentity);
237
+ for (const route of candidateRoutes(rect, threshold)) {
238
+ if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue;
239
+ let nearest = null;
240
+ for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) {
241
+ const start = route.points[segmentIndex];
242
+ const end = route.points[segmentIndex + 1];
243
+ const clearance = segmentRectClearance({ start, end }, rect);
244
+ if (clearance == null) continue;
245
+ if (!nearest || clearance < nearest.clearance) {
246
+ nearest = {
247
+ clearance,
248
+ intersectionLength: segmentRectIntersectionLength({ start, end }, rect),
249
+ segmentIndex,
250
+ start,
251
+ end,
252
+ };
253
+ }
254
+ }
255
+ if (!nearest || nearest.clearance + 0.0001 >= threshold) continue;
256
+ hits.push({
257
+ label,
258
+ labelRelation: label?.relation,
259
+ labelRelationIndex: relationIndex,
260
+ otherRelation: route.relation,
261
+ otherRelationIndex: route.relationIndex,
262
+ rect,
263
+ ...nearest,
264
+ threshold,
265
+ });
266
+ }
267
+ }
268
+ return hits;
269
+ }
270
+
271
+ export function minimumLabelRouteClearance(measurements) {
272
+ if (!asArray(measurements).length) return null;
273
+ const minimum = measurements.reduce(
274
+ (value, hit) => Math.min(value, hit.clearance),
275
+ Infinity,
276
+ );
277
+ return Math.round(minimum * 10) / 10;
278
+ }
279
+
280
+ function relationshipIdentity(relation, relationIndex) {
281
+ if (relation?.key !== undefined) return `key:${relation.key}`;
282
+ if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`;
283
+ return `index:${relationIndex}`;
284
+ }
285
+
286
+ function sameRelationship(left, right) {
287
+ if (!left || !right) return false;
288
+ if (left === right) return true;
289
+ if (left.key !== undefined && right.key !== undefined) return left.key === right.key;
290
+ return Boolean(left.id && right.id && left.id === right.id && left.from === right.from && left.to === right.to);
291
+ }
292
+
293
+ function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
294
+ return {
295
+ diagramType,
296
+ collection: relationCollection,
297
+ index: relationIndex,
298
+ ...(relation?.id ? { id: relation.id } : {}),
299
+ ...(relation?.from ? { from: relation.from } : {}),
300
+ ...(relation?.to ? { to: relation.to } : {}),
301
+ };
302
+ }
303
+
304
+ const AUTHORED_PLAN_KEYS = ['route', 'via', 'channelX', 'channelY', 'fromSide', 'toSide', 'bias', 'labelAt', 'labelDx', 'labelDy', 'labelSegment'];
305
+
306
+ // Re-planning advice only applies when the failing relation actually carries
307
+ // authored controls; telling an author to remove controls that do not exist
308
+ // just sends them hunting for a field that was never written.
309
+ function rePlanHint(relations, fallback) {
310
+ const controls = new Set();
311
+ for (const relation of asArray(relations)) {
312
+ for (const key of AUTHORED_PLAN_KEYS) if (relation?.[key] !== undefined) controls.add(key);
313
+ }
314
+ if (!controls.size) return fallback;
315
+ return `if the authored ${[...controls].join('/')} are not required by the user, remove them so the renderer can re-plan; otherwise preserve that intent and ${fallback}`;
316
+ }
317
+
318
+ const ENDPOINT_SIDE_RULES = {
319
+ left: {
320
+ axis: 'horizontal',
321
+ sourceSign: -1,
322
+ targetSign: 1,
323
+ sourceDirection: 'leftward',
324
+ targetDirection: 'rightward from the left',
325
+ },
326
+ right: {
327
+ axis: 'horizontal',
328
+ sourceSign: 1,
329
+ targetSign: -1,
330
+ sourceDirection: 'rightward',
331
+ targetDirection: 'leftward from the right',
332
+ },
333
+ top: {
334
+ axis: 'vertical',
335
+ sourceSign: -1,
336
+ targetSign: 1,
337
+ sourceDirection: 'upward',
338
+ targetDirection: 'downward from above',
339
+ },
340
+ bottom: {
341
+ axis: 'vertical',
342
+ sourceSign: 1,
343
+ targetSign: -1,
344
+ sourceDirection: 'downward',
345
+ targetDirection: 'upward from below',
346
+ },
347
+ };
348
+
349
+ function endpointSideIssue(points, endpoint, side) {
350
+ const rule = ENDPOINT_SIDE_RULES[side];
351
+ if (!rule) return null;
352
+ const normalized = normalizeRoutePoints(points);
353
+ if (normalized.length < 2) return null;
354
+ const segmentIndex = endpoint === 'source' ? 0 : normalized.length - 2;
355
+ const start = normalized[segmentIndex];
356
+ const end = normalized[segmentIndex + 1];
357
+ const dx = end[0] - start[0];
358
+ const dy = end[1] - start[1];
359
+ const along = rule.axis === 'horizontal' ? dx : dy;
360
+ const across = rule.axis === 'horizontal' ? dy : dx;
361
+ const expectedSign = endpoint === 'source' ? rule.sourceSign : rule.targetSign;
362
+ if (Math.abs(across) <= 0.0001 && along * expectedSign > 0.0001) return null;
363
+ return {
364
+ endpoint,
365
+ side,
366
+ segmentIndex,
367
+ start,
368
+ end,
369
+ expectedAxis: rule.axis,
370
+ expectedDirection: endpoint === 'source' ? rule.sourceDirection : rule.targetDirection,
371
+ };
372
+ }
373
+
374
+ // A side is a direction contract, not just a point on a box border. This pure
375
+ // predicate lets automatic routers prefer a dogleg whose first and final
376
+ // segments leave/enter the chosen sides perpendicularly.
377
+ export function routeHonorsEndpointSides(points, fromSide, toSide) {
378
+ return !endpointSideIssue(points, 'source', fromSide)
379
+ && !endpointSideIssue(points, 'target', toSide);
380
+ }
381
+ // Explicit fromSide/toSide are authored geometry, so a tangent or backwards
382
+ // endpoint segment changes their meaning. Fail this universally instead of
383
+ // leaving a malformed arrow for visual review to discover. Named routes and
384
+ // authored via points already carry their own geometry semantics: when they
385
+ // omit endpoint sides, do not invent a relative-position side and then reject
386
+ // the route for disagreeing with that invention. Pure automatic routes may
387
+ // still be checked against renderer-inferred sides.
388
+ export function cleanEndpointSideProblems({
389
+ relations,
390
+ endpointIds,
391
+ pathFor,
392
+ diagramType,
393
+ relationCollection,
394
+ fromSideFor,
395
+ toSideFor,
396
+ shouldCheckRelation = () => true,
397
+ routeHint = 'align the first/final via segment with fromSide/toSide, change the side, or remove explicit routing so auto can choose a perpendicular approach',
398
+ }) {
399
+ const problems = [];
400
+ for (const [relationIndex, relation] of asArray(relations).entries()) {
401
+ if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
402
+ if (!shouldCheckRelation(relation, relationIndex)) continue;
403
+ const points = pathFor(relation)?.points;
404
+ if (!Array.isArray(points) || points.length < 2) continue;
405
+ const authoredFromSide = relation.fromSide && relation.fromSide !== 'auto' ? relation.fromSide : null;
406
+ const authoredToSide = relation.toSide && relation.toSide !== 'auto' ? relation.toSide : null;
407
+ const hasAuthoredRouteGeometry = Boolean(
408
+ (relation.route && relation.route !== 'auto') || Array.isArray(relation.via),
409
+ );
410
+ const inferredFromSide = !hasAuthoredRouteGeometry && typeof fromSideFor === 'function'
411
+ ? fromSideFor(relation)
412
+ : null;
413
+ const inferredToSide = !hasAuthoredRouteGeometry && typeof toSideFor === 'function'
414
+ ? toSideFor(relation)
415
+ : null;
416
+ const fromSide = authoredFromSide ?? inferredFromSide;
417
+ const toSide = authoredToSide ?? inferredToSide;
418
+ const checks = [
419
+ fromSide
420
+ ? { ...endpointSideIssue(points, 'source', fromSide), sideOrigin: authoredFromSide ? 'authored' : 'inferred' }
421
+ : null,
422
+ toSide
423
+ ? { ...endpointSideIssue(points, 'target', toSide), sideOrigin: authoredToSide ? 'authored' : 'inferred' }
424
+ : null,
425
+ ].filter((issue) => issue?.endpoint);
426
+ for (const issue of checks) {
427
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
428
+ const authoredField = issue.endpoint === 'source' ? 'fromSide' : 'toSide';
429
+ const sideField = issue.sideOrigin === 'inferred' ? `inferred ${authoredField}` : authoredField;
430
+ const segmentRole = issue.endpoint === 'source' ? 'first' : 'final';
431
+ const from = issue.start.map((value) => Math.round(value * 10) / 10).join(', ');
432
+ const to = issue.end.map((value) => Math.round(value * 10) / 10).join(', ');
433
+ const message = `[clean-flow/endpoint-side-direction] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" ${segmentRole} segment ${issue.segmentIndex} [${from}] -> [${to}] does not honor ${sideField} "${issue.side}" — it must run ${issue.expectedAxis} ${issue.expectedDirection}; ${routeHint}.`;
434
+ recordDiagnostic({
435
+ code: 'clean-flow/endpoint-side-direction',
436
+ severity: 'error',
437
+ message,
438
+ subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
439
+ evidence: {
440
+ endpoint: issue.endpoint,
441
+ authoredField,
442
+ sideOrigin: issue.sideOrigin,
443
+ side: issue.side,
444
+ segmentIndex: issue.segmentIndex,
445
+ from: issue.start,
446
+ to: issue.end,
447
+ expectedAxis: issue.expectedAxis,
448
+ expectedDirection: issue.expectedDirection,
449
+ },
450
+ supportedFixes: [routeHint],
451
+ });
452
+ problems.push(message);
453
+ }
454
+ }
455
+ return problems;
456
+ }
457
+
458
+ // One mechanical quality gate for every renderer-owned relationship path.
459
+ // A renderer supplies its semantic obstacle set; source/target boxes are
460
+ // always exempt because paths are expected to terminate on their boundaries.
461
+ // Containers, lifelines, and other intentionally pass-through geometry should
462
+ // simply not be supplied as obstacles.
463
+ export function cleanFlowProblems({
464
+ relations,
465
+ obstacles,
466
+ pathFor,
467
+ diagramType,
468
+ relationCollection,
469
+ obstacleKind,
470
+ clearance = 2,
471
+ routeHint = 'adjust fromSide/toSide, set route/via or channel coordinates, or move the obstacle'
472
+ }) {
473
+ // A relationship hidden behind an unrelated opaque node changes the
474
+ // diagram's meaning, so this is a correctness invariant rather than an
475
+ // opt-in composition preference. Keep it active even when the author omits
476
+ // quality_profile; standard/showcase still control stricter visual budgets.
477
+ const problems = [];
478
+ const obstacleList = [...obstacles];
479
+ const obstacleIds = new Set(obstacleList.map((obstacle) => obstacle?.id));
480
+ for (const [relationIndex, relation] of asArray(relations).entries()) {
481
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') continue;
482
+ if (!obstacleIds.has(relation.from) || !obstacleIds.has(relation.to)) continue;
483
+ const points = pathFor(relation)?.points;
484
+ if (!Array.isArray(points) || points.length < 2) continue;
485
+ if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) continue;
486
+
487
+ const endpointIds = new Set([relation.from, relation.to]);
488
+ for (const obstacle of obstacleList) {
489
+ if (!obstacle || endpointIds.has(obstacle.id)) continue;
490
+ if (!isFinitePoint(obstacle.x, obstacle.y, obstacle.width, obstacle.height)) continue;
491
+ let hitSegment = -1;
492
+ for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
493
+ if (segmentIntersectsRect({ start: points[segmentIndex], end: points[segmentIndex + 1] }, obstacle, clearance)) {
494
+ hitSegment = segmentIndex;
495
+ break;
496
+ }
497
+ }
498
+ if (hitSegment === -1) continue;
499
+ const from = points[hitSegment].map(Math.round).join(', ');
500
+ const to = points[hitSegment + 1].map(Math.round).join(', ');
501
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
502
+ const hint = rePlanHint([relation], routeHint);
503
+ const message = `[clean-flow/edge-through-node] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" crosses ${obstacleKind} "${obstacle.id}" (unrelated to this relationship) on segment ${hitSegment} [${from}] -> [${to}] (${clearance}px clearance) — ${hint}.`;
504
+ recordDiagnostic({
505
+ code: 'clean-flow/edge-through-node',
506
+ severity: 'error',
507
+ message,
508
+ subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
509
+ evidence: {
510
+ obstacleKind,
511
+ obstacleId: obstacle.id,
512
+ segmentIndex: hitSegment,
513
+ from: points[hitSegment],
514
+ to: points[hitSegment + 1],
515
+ clearancePx: clearance,
516
+ },
517
+ supportedFixes: [hint],
518
+ });
519
+ problems.push(message);
520
+ }
521
+ }
522
+ return problems;
523
+ }
524
+
525
+ // Build a read-only analysis copy of a polyline with straight-through
526
+ // waypoints removed. A waypoint on a forward-collinear run is not a visual
527
+ // endpoint, so treating it as one would hide a proper X that lands exactly on
528
+ // that waypoint. Reversals and real bends stay split: their shared point can
529
+ // still be an authored touch rather than a crossing. The source points are
530
+ // retained on every merged segment so diagnostics can name the authored
531
+ // segment that contains a hit without changing rendered/receipt geometry.
532
+ export function forwardCollinearAnalysisSegments(points) {
533
+ const segments = [];
534
+ for (let segmentIndex = 0; segmentIndex < asArray(points).length - 1; segmentIndex += 1) {
535
+ const authoredStart = points[segmentIndex];
536
+ const authoredEnd = points[segmentIndex + 1];
537
+ const start = Array.isArray(authoredStart) ? [...authoredStart] : authoredStart;
538
+ const end = Array.isArray(authoredEnd) ? [...authoredEnd] : authoredEnd;
539
+ const sourceSegment = { start, end, segmentIndex };
540
+ const previous = segments.at(-1);
541
+ if (previous && segmentsContinueForward(previous.start, previous.end, start, end)) {
542
+ previous.end = end;
543
+ previous.sourceSegments.push(sourceSegment);
544
+ continue;
545
+ }
546
+ segments.push({
547
+ start,
548
+ end,
549
+ segmentIndex,
550
+ sourceSegments: [sourceSegment],
551
+ });
552
+ }
553
+ return segments;
554
+ }
555
+
556
+ export function sourceSegmentIndexAtPoint(segment, point) {
557
+ const source = asArray(segment?.sourceSegments).find(({ start, end }) => (
558
+ pointLiesOnSegment(point, start, end)
559
+ ));
560
+ return source?.segmentIndex ?? segment?.segmentIndex ?? 0;
561
+ }
562
+
563
+ function authoredAnalysisSegments(points) {
564
+ return asArray(points).slice(0, -1).map((start, segmentIndex) => ({
565
+ start,
566
+ end: points[segmentIndex + 1],
567
+ segmentIndex,
568
+ sourceSegments: [{ start, end: points[segmentIndex + 1], segmentIndex }],
569
+ }));
570
+ }
571
+
572
+ function segmentsContinueForward(firstStart, firstEnd, secondStart, secondEnd) {
573
+ if (![firstStart, firstEnd, secondStart, secondEnd].every((point) => (
574
+ Array.isArray(point) && point.length === 2 && isFinitePoint(...point)
575
+ ))) return false;
576
+ const epsilon = 0.0001;
577
+ if (Math.abs(firstEnd[0] - secondStart[0]) > epsilon
578
+ || Math.abs(firstEnd[1] - secondStart[1]) > epsilon) return false;
579
+ const firstVector = [firstEnd[0] - firstStart[0], firstEnd[1] - firstStart[1]];
580
+ const secondVector = [secondEnd[0] - secondStart[0], secondEnd[1] - secondStart[1]];
581
+ const firstLength = Math.hypot(...firstVector);
582
+ const secondLength = Math.hypot(...secondVector);
583
+ if (firstLength <= epsilon || secondLength <= epsilon) return false;
584
+ const cross = firstVector[0] * secondVector[1] - firstVector[1] * secondVector[0];
585
+ if (Math.abs(cross) > epsilon) return false;
586
+ const dot = firstVector[0] * secondVector[0] + firstVector[1] * secondVector[1];
587
+ return dot > epsilon;
588
+ }
589
+
590
+ function pointLiesOnSegment(point, start, end) {
591
+ if (![point, start, end].every((candidate) => (
592
+ Array.isArray(candidate) && candidate.length === 2 && isFinitePoint(...candidate)
593
+ ))) return false;
594
+ const epsilon = 0.0001;
595
+ const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
596
+ if (length <= epsilon) return Math.hypot(point[0] - start[0], point[1] - start[1]) <= epsilon;
597
+ if (Math.abs(crossProduct(start, end, point)) > epsilon * length) return false;
598
+ return point[0] >= Math.min(start[0], end[0]) - epsilon
599
+ && point[0] <= Math.max(start[0], end[0]) + epsilon
600
+ && point[1] >= Math.min(start[1], end[1]) - epsilon
601
+ && point[1] <= Math.max(start[1], end[1]) + epsilon;
602
+ }
603
+
604
+ // Authored shared endpoints retain their junction interpretation by default.
605
+ // Renderers can opt their automatic routes into proper interior X checks;
606
+ // endpoint touches and collinear trunks still are not proper crossings.
607
+ export function cleanCrossingProblems({
608
+ relations,
609
+ endpointIds,
610
+ pathFor,
611
+ diagramType,
612
+ relationCollection,
613
+ profile = 'standard',
614
+ profileIsAuthoritative = false,
615
+ mergeForwardCollinearWaypoints = false,
616
+ includeSharedEndpoints = () => false,
617
+ crossingResolved = () => false,
618
+ warnInStandard = false,
619
+ onDiagnostic = recordDiagnostic,
620
+ routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
621
+ }) {
622
+ const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
623
+ if (severity === 'warning' && !warnInStandard) return [];
624
+ const routed = asArray(relations).map((relation, index) => {
625
+ if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
626
+ const points = pathFor(relation)?.points;
627
+ if (!Array.isArray(points) || points.length < 2) return null;
628
+ if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) return null;
629
+ return {
630
+ relation,
631
+ index,
632
+ points,
633
+ analysisSegments: mergeForwardCollinearWaypoints
634
+ ? forwardCollinearAnalysisSegments(points)
635
+ : authoredAnalysisSegments(points),
636
+ };
637
+ }).filter(Boolean);
638
+ const problems = [];
639
+
640
+ for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
641
+ const left = routed[leftIndex];
642
+ for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
643
+ const right = routed[rightIndex];
644
+ if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)
645
+ && !includeSharedEndpoints(left.relation, right.relation)) continue;
646
+
647
+ let hit = null;
648
+ for (const leftSegment of left.analysisSegments) {
649
+ if (hit) break;
650
+ for (const rightSegment of right.analysisSegments) {
651
+ const point = properSegmentIntersection(
652
+ leftSegment.start,
653
+ leftSegment.end,
654
+ rightSegment.start,
655
+ rightSegment.end
656
+ );
657
+ if (point) {
658
+ hit = {
659
+ point,
660
+ leftSegment: sourceSegmentIndexAtPoint(leftSegment, point),
661
+ rightSegment: sourceSegmentIndexAtPoint(rightSegment, point),
662
+ };
663
+ break;
664
+ }
665
+ }
666
+ }
667
+ if (!hit) continue;
668
+ if (crossingResolved(left.relation, right.relation, hit)) continue;
669
+
670
+ const describe = ({ relation, index }) => {
671
+ const id = relation.id ? ` id "${relation.id}"` : '';
672
+ return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`;
673
+ };
674
+ const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', ');
675
+ const hint = rePlanHint([left.relation, right.relation], routeHint);
676
+ const message = `[composition/proper-crossing] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${hint}.`;
677
+ onDiagnostic({
678
+ code: 'composition/proper-crossing',
679
+ severity,
680
+ message,
681
+ subject: relationshipSubject(diagramType, relationCollection, left.index, left.relation),
682
+ evidence: {
683
+ otherRelationship: relationshipSubject(diagramType, relationCollection, right.index, right.relation),
684
+ point: hit.point,
685
+ segmentIndex: hit.leftSegment,
686
+ otherSegmentIndex: hit.rightSegment,
687
+ },
688
+ supportedFixes: [hint],
689
+ });
690
+ if (severity === 'error') problems.push(message);
691
+ }
692
+ }
693
+ return problems;
694
+ }
695
+
696
+ // Two unrelated relationships that occupy the same visible corridor can read
697
+ // as one authored branch or merge even when neither relationship crosses a
698
+ // node or forms a proper X. Keep authored shared endpoints exempt by default;
699
+ // automatic architecture routes opt in because they promise separate ports.
700
+ // Workflow v2 opts into shared-endpoint checks with a bounded terminal-trunk
701
+ // exception. Other callers keep their existing authored-junction contract.
702
+ // The counterflow-only opt-in serves older workflow exports without a root
703
+ // readable-v2 contract; full shared-endpoint checking takes precedence.
704
+ // Tiny overlaps below the route rhythm
705
+ // floor are ignored to avoid turning sub-pixel rounding into a quality debt.
706
+ export function collectAmbiguousCorridors({
707
+ routedRelations,
708
+ minOverlapPx = 8,
709
+ includeSharedEndpoints = () => false,
710
+ includeSharedEndpointCounterflow = () => false,
711
+ allowShortWorkflowTrunks = false,
712
+ }) {
713
+ const routed = asArray(routedRelations).map((entry, fallbackIndex) => {
714
+ const relation = entry?.relation;
715
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
716
+ const points = normalizeRoutePoints(entry?.points);
717
+ if (points.length < 2) return null;
718
+ return {
719
+ relation,
720
+ relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex,
721
+ points,
722
+ };
723
+ }).filter(Boolean);
724
+ const hits = [];
725
+
726
+ for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
727
+ const left = routed[leftIndex];
728
+ for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
729
+ const right = routed[rightIndex];
730
+ const sharedEndpoint = [left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to);
731
+ const counterflowOnly = sharedEndpoint && !includeSharedEndpoints(left.relation, right.relation);
732
+ if (counterflowOnly && !includeSharedEndpointCounterflow(left.relation, right.relation)) continue;
733
+
734
+ let longest = null;
735
+ for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) {
736
+ for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
737
+ const overlap = collinearAxisOverlap(
738
+ left.points[leftSegment],
739
+ left.points[leftSegment + 1],
740
+ right.points[rightSegment],
741
+ right.points[rightSegment + 1],
742
+ );
743
+ if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue;
744
+ if (allowShortWorkflowTrunks && shortWorkflowTrunk(left, right, leftSegment, rightSegment, overlap.length)) continue;
745
+ if (counterflowOnly) {
746
+ const leftDelta = left.points[leftSegment + 1].map((value, axis) => value - left.points[leftSegment][axis]);
747
+ const rightDelta = right.points[rightSegment + 1].map((value, axis) => value - right.points[rightSegment][axis]);
748
+ if (leftDelta[0] * rightDelta[0] + leftDelta[1] * rightDelta[1] >= 0) continue;
749
+ }
750
+ if (!longest || overlap.length > longest.overlapLength + 0.0001) {
751
+ longest = {
752
+ left,
753
+ right,
754
+ leftSegment,
755
+ rightSegment,
756
+ overlapLength: overlap.length,
757
+ overlapStart: overlap.start,
758
+ overlapEnd: overlap.end,
759
+ };
760
+ }
761
+ }
762
+ }
763
+ if (longest) hits.push(longest);
764
+ }
765
+ }
766
+ return hits;
767
+ }
768
+
769
+ function shortWorkflowTrunk(left, right, leftSegment, rightSegment, length) {
770
+ if (length > 24 + 0.0001) return false;
771
+ const a = left.relation, b = right.relation;
772
+ const variant = (edge) => edge.variant || 'default';
773
+ const width = (edge) => edge.width || (variant(edge) === 'emphasis' ? 1.8 : 1.4);
774
+ if (variant(a) !== variant(b) || width(a) !== width(b) || (a.role || '') !== (b.role || '')) return false;
775
+ const same = (p, q) => Math.abs(p[0] - q[0]) < 0.0001 && Math.abs(p[1] - q[1]) < 0.0001;
776
+ const source = a.from === b.from && leftSegment === 0 && rightSegment === 0
777
+ && same(left.points[0], right.points[0]);
778
+ const target = a.to === b.to && leftSegment === left.points.length - 2 && rightSegment === right.points.length - 2
779
+ && same(left.points.at(-1), right.points.at(-1));
780
+ if (!source && !target) return false;
781
+ const p = left.points[leftSegment], q = left.points[leftSegment + 1];
782
+ const r = right.points[rightSegment], s = right.points[rightSegment + 1];
783
+ return (q[0] - p[0]) * (s[0] - r[0]) + (q[1] - p[1]) * (s[1] - r[1]) > 0;
784
+ }
785
+
786
+ // Bundled arrow markers are 7 stroke-widths across the direction of travel.
787
+ // Callers select the automatic routes they own; explicit junctions are preserved.
788
+ export function collectArrowheadCollisions({ routedRelations, allowShortWorkflowTrunks = false }) {
789
+ const incoming = new Map();
790
+ const hits = [];
791
+ for (const entry of asArray(routedRelations)) {
792
+ const points = normalizeRoutePoints(entry.points);
793
+ if (points.length < 2 || !entry.relation?.to) continue;
794
+ const tip = points.at(-1);
795
+ const previous = points.at(-2);
796
+ const vertical = Math.abs(tip[0] - previous[0]) < 0.0001;
797
+ const axis = vertical ? 0 : 1;
798
+ const direction = Math.sign(tip[1 - axis] - previous[1 - axis]);
799
+ const halfWidth = 3.5 * (entry.relation.width || (entry.relation.variant === 'emphasis' ? 1.8 : 1.5));
800
+ const key = `${entry.relation.to}\u0000${axis}\u0000${direction}`;
801
+ const siblings = incoming.get(key) || [];
802
+ const current = { ...entry, tip, halfWidth };
803
+ for (const sibling of siblings) {
804
+ if (Math.abs(tip[1 - axis] - sibling.tip[1 - axis]) > 0.0001) continue;
805
+ const distance = Math.abs(tip[axis] - sibling.tip[axis]);
806
+ const minimum = halfWidth + sibling.halfWidth;
807
+ if (allowShortWorkflowTrunks) {
808
+ const left = { ...sibling, points: normalizeRoutePoints(sibling.points) };
809
+ const right = { ...entry, points };
810
+ const overlap = collinearAxisOverlap(left.points.at(-2), left.points.at(-1), points.at(-2), tip);
811
+ if (overlap && shortWorkflowTrunk(left, right, left.points.length - 2, points.length - 2, overlap.length)) continue;
812
+ }
813
+ if (distance < minimum - 0.0001) hits.push({ left: sibling, right: current, distance, minimum });
814
+ }
815
+ siblings.push(current);
816
+ incoming.set(key, siblings);
817
+ }
818
+ return hits;
819
+ }
820
+
821
+ export function cleanAmbiguousCorridorProblems({
822
+ relations,
823
+ endpointIds,
824
+ pathFor,
825
+ diagramType,
826
+ relationCollection,
827
+ profile = 'standard',
828
+ profileIsAuthoritative = false,
829
+ routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
830
+ minOverlapPx = 8,
831
+ includeSharedEndpoints = () => false,
832
+ includeSharedEndpointCounterflow = () => false,
833
+ allowShortWorkflowTrunks = false,
834
+ onDiagnostic = recordDiagnostic,
835
+ }) {
836
+ const severity = qualityProfileForGate(profile, profileIsAuthoritative) === 'showcase' ? 'error' : 'warning';
837
+ if (severity === 'warning' && !allowShortWorkflowTrunks) return [];
838
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
839
+
840
+ return collectAmbiguousCorridors({ routedRelations, minOverlapPx, includeSharedEndpoints, includeSharedEndpointCounterflow, allowShortWorkflowTrunks }).map((hit) => {
841
+ const describe = ({ relation, relationIndex }) => {
842
+ const id = relation.id ? ` id "${relation.id}"` : '';
843
+ return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`;
844
+ };
845
+ const length = Math.round(hit.overlapLength * 10) / 10;
846
+ const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
847
+ const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
848
+ const hint = rePlanHint([hit.left.relation, hit.right.relation], routeHint);
849
+ const message = `[composition/ambiguous-corridor] ${severity === 'error' ? 'showcase' : 'standard'} ${diagramType} ${describe(hit.left)} shares a ${length}px corridor with ${describe(hit.right)} at [${from}] -> [${to}] (segments ${hit.leftSegment} and ${hit.rightSegment}; minimum ${minOverlapPx}px) — ${hint}.`;
850
+ onDiagnostic({
851
+ code: 'composition/ambiguous-corridor',
852
+ severity,
853
+ message,
854
+ subject: relationshipSubject(diagramType, relationCollection, hit.left.relationIndex, hit.left.relation),
855
+ evidence: {
856
+ otherRelationship: relationshipSubject(diagramType, relationCollection, hit.right.relationIndex, hit.right.relation),
857
+ overlapLengthPx: length,
858
+ minimumPx: minOverlapPx,
859
+ from: hit.overlapStart,
860
+ to: hit.overlapEnd,
861
+ segmentIndex: hit.leftSegment,
862
+ otherSegmentIndex: hit.rightSegment,
863
+ },
864
+ supportedFixes: [hint],
865
+ });
866
+ return severity === 'error' ? message : null;
867
+ }).filter(Boolean);
868
+ }
869
+
870
+ // Relationship paths may cross a structural frame, but they must not borrow a
871
+ // frame side as a routing corridor. Rounded rectangle corners are trimmed from
872
+ // the modeled straight sides so a short corner touch is not mistaken for a
873
+ // border run. Any positive straight overlap beyond the numeric epsilon is a
874
+ // hard failure in every quality profile; 16px belongs only to the separate,
875
+ // neutral short-segment metric and is not a corridor exemption.
876
+ export function collectBorderRuns({ routedRelations, frames }) {
877
+ const hits = [];
878
+ for (const routed of asArray(routedRelations)) {
879
+ const routeSegments = Array.isArray(routed?.segments)
880
+ ? routed.segments
881
+ : asArray(routed?.points).slice(0, -1).map((start, index) => ({ start, end: routed.points[index + 1] }));
882
+ if (!routeSegments.length) continue;
883
+ if (!routeSegments.every((segment) => (
884
+ Array.isArray(segment?.start) && segment.start.length === 2 && isFinitePoint(...segment.start)
885
+ && Array.isArray(segment?.end) && segment.end.length === 2 && isFinitePoint(...segment.end)
886
+ ))) continue;
887
+ for (const [frameIndex, frame] of asArray(frames).entries()) {
888
+ for (const border of frameBorderSegments(frame)) {
889
+ const overlaps = [];
890
+ for (let segmentIndex = 0; segmentIndex < routeSegments.length; segmentIndex += 1) {
891
+ const segment = routeSegments[segmentIndex];
892
+ const overlap = collinearAxisOverlap(
893
+ segment.start,
894
+ segment.end,
895
+ border.start,
896
+ border.end,
897
+ );
898
+ if (!overlap || overlap.length <= 0.0001) continue;
899
+ overlaps.push({ ...overlap, segmentIndex });
900
+ }
901
+ if (!overlaps.length) continue;
902
+ const merged = mergeBorderOverlaps(overlaps, border);
903
+ const longest = [...merged].sort((left, right) => right.length - left.length || left.low - right.low)[0];
904
+ hits.push({
905
+ ...routed,
906
+ frame,
907
+ frameIndex,
908
+ side: border.side,
909
+ segmentIndex: overlaps.reduce((min, overlap) => Math.min(min, overlap.segmentIndex), Infinity),
910
+ overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0),
911
+ overlapStart: longest.start,
912
+ overlapEnd: longest.end,
913
+ });
914
+ }
915
+ }
916
+ }
917
+ return hits;
918
+ }
919
+
920
+ export function cleanBorderRunProblems({
921
+ relations,
922
+ endpointIds,
923
+ frames,
924
+ pathFor,
925
+ diagramType,
926
+ relationCollection,
927
+ profile,
928
+ profileIsAuthoritative = false,
929
+ routeHint = 'adjust route/via or channel coordinates so the relationship crosses the frame perpendicularly through a clear opening'
930
+ }) {
931
+ if (!qualityProfileForGate(profile, profileIsAuthoritative)) return [];
932
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
933
+ return collectBorderRuns({ routedRelations, frames }).map((hit) => {
934
+ const relation = hit.relation || {};
935
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
936
+ const frameKind = hit.frame?.kind || hit.frame?.shape || 'frame';
937
+ const frameIdentity = hit.frame?.label || hit.frame?.id || hit.frameIndex;
938
+ const length = Math.round(hit.overlapLength * 10) / 10;
939
+ const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
940
+ const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
941
+ const hint = rePlanHint([relation], routeHint);
942
+ const message = `[composition/container-border-run] ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" follows ${frameKind} "${frameIdentity}" ${hit.side} border for ${length}px on segment ${hit.segmentIndex} [${from}] -> [${to}] — ${hint}.`;
943
+ recordDiagnostic({
944
+ code: 'composition/container-border-run',
945
+ severity: 'error',
946
+ message,
947
+ subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
948
+ evidence: {
949
+ frameKind,
950
+ frameId: hit.frame?.id,
951
+ frameLabel: hit.frame?.label,
952
+ side: hit.side,
953
+ segmentIndex: hit.segmentIndex,
954
+ overlapLengthPx: length,
955
+ from: hit.overlapStart,
956
+ to: hit.overlapEnd,
957
+ },
958
+ supportedFixes: [hint],
959
+ });
960
+ return message;
961
+ });
962
+ }
963
+
964
+ export function routeBudgetMetrics({
965
+ routedRelations,
966
+ bendsPerRelationship = 2,
967
+ stretch = 1.35,
968
+ segmentPx = 16,
969
+ microSegmentPx = 8,
970
+ }) {
971
+ let maxBends = 0;
972
+ let routesOverSuggestedBends = 0;
973
+ let maxStretch = null;
974
+ let routesOverSuggestedStretch = 0;
975
+ let minSegmentPx = null;
976
+ let minInteriorSegmentPx = null;
977
+ let shortSegmentCount = 0;
978
+ let shortEndpointSegmentCount = 0;
979
+ let shortInteriorSegmentCount = 0;
980
+ let microSegmentCount = 0;
981
+
982
+ for (const routed of asArray(routedRelations)) {
983
+ const points = normalizeRoutePoints(routed?.points);
984
+ if (points.length < 2) continue;
985
+ const bends = Math.max(0, points.length - 2);
986
+ maxBends = Math.max(maxBends, bends);
987
+ if (bends > bendsPerRelationship) routesOverSuggestedBends += 1;
988
+
989
+ let routeLength = 0;
990
+ for (let index = 0; index < points.length - 1; index += 1) {
991
+ const length = Math.abs(points[index + 1][0] - points[index][0]) + Math.abs(points[index + 1][1] - points[index][1]);
992
+ if (length <= 0.0001) continue;
993
+ const position = segmentPosition(index, points.length - 1);
994
+ routeLength += length;
995
+ minSegmentPx = minSegmentPx == null ? length : Math.min(minSegmentPx, length);
996
+ if (position === 'interior') {
997
+ minInteriorSegmentPx = minInteriorSegmentPx == null ? length : Math.min(minInteriorSegmentPx, length);
998
+ }
999
+ if (length < segmentPx) {
1000
+ shortSegmentCount += 1;
1001
+ if (position === 'interior') shortInteriorSegmentCount += 1;
1002
+ else shortEndpointSegmentCount += 1;
1003
+ }
1004
+ if (length < microSegmentPx) microSegmentCount += 1;
1005
+ }
1006
+ const direct = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]);
1007
+ if (direct > 0.0001) {
1008
+ const routeStretch = routeLength / direct;
1009
+ maxStretch = maxStretch == null ? routeStretch : Math.max(maxStretch, routeStretch);
1010
+ if (routeStretch > stretch + 0.0001) routesOverSuggestedStretch += 1;
1011
+ }
1012
+ }
1013
+
1014
+ return {
1015
+ maxBends,
1016
+ routesOverSuggestedBends,
1017
+ maxStretch,
1018
+ routesOverSuggestedStretch,
1019
+ minSegmentPx,
1020
+ minInteriorSegmentPx,
1021
+ shortSegmentCount,
1022
+ shortEndpointSegmentCount,
1023
+ shortInteriorSegmentCount,
1024
+ microSegmentCount,
1025
+ };
1026
+ }
1027
+
1028
+ export function collectRouteRhythmIssues({
1029
+ routedRelations,
1030
+ interiorSegmentPx = 16,
1031
+ microSegmentPx = 8,
1032
+ }) {
1033
+ const issues = [];
1034
+ for (const [fallbackIndex, routed] of asArray(routedRelations).entries()) {
1035
+ const points = normalizeRoutePoints(routed?.points);
1036
+ if (points.length < 2) continue;
1037
+ for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
1038
+ const start = points[segmentIndex];
1039
+ const end = points[segmentIndex + 1];
1040
+ const length = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
1041
+ if (length <= 0.0001) continue;
1042
+ const position = segmentPosition(segmentIndex, points.length - 1);
1043
+ const code = length < microSegmentPx - 0.0001
1044
+ ? 'composition/micro-segment'
1045
+ : position === 'interior' && length < interiorSegmentPx - 0.0001
1046
+ ? 'composition/short-interior-segment'
1047
+ : null;
1048
+ if (!code) continue;
1049
+ issues.push({
1050
+ code,
1051
+ relation: routed.relation,
1052
+ relationIndex: Number.isInteger(routed.relationIndex) ? routed.relationIndex : fallbackIndex,
1053
+ segmentIndex,
1054
+ position,
1055
+ length,
1056
+ start,
1057
+ end,
1058
+ });
1059
+ }
1060
+ }
1061
+ return issues;
1062
+ }
1063
+
1064
+ export function cleanRouteRhythmProblems({
1065
+ relations,
1066
+ endpointIds,
1067
+ pathFor,
1068
+ diagramType,
1069
+ relationCollection,
1070
+ profile,
1071
+ profileIsAuthoritative = false,
1072
+ routeHint = 'move the channel/via point to remove the cramped turn or give the route more corridor space',
1073
+ interiorSegmentPx = 16,
1074
+ microSegmentPx = 8,
1075
+ }) {
1076
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
1077
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
1078
+ return collectRouteRhythmIssues({ routedRelations, interiorSegmentPx, microSegmentPx }).map((hit) => {
1079
+ const relation = hit.relation || {};
1080
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
1081
+ const length = Math.round(hit.length * 10) / 10;
1082
+ const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
1083
+ const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
1084
+ const rule = hit.code === 'composition/micro-segment'
1085
+ ? `is below the ${microSegmentPx}px micro-segment floor`
1086
+ : `is below the ${interiorSegmentPx}px interior-segment floor`;
1087
+ const hint = rePlanHint([relation], routeHint);
1088
+ const message = `[${hit.code}] showcase ${diagramType} ${relationCollection}[${hit.relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" has a ${length}px ${hit.position} segment ${hit.segmentIndex} [${from}] -> [${to}] that ${rule} — ${hint}.`;
1089
+ recordDiagnostic({
1090
+ code: hit.code,
1091
+ severity: 'error',
1092
+ message,
1093
+ subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
1094
+ evidence: {
1095
+ segmentIndex: hit.segmentIndex,
1096
+ position: hit.position,
1097
+ lengthPx: length,
1098
+ minimumPx: hit.code === 'composition/micro-segment' ? microSegmentPx : interiorSegmentPx,
1099
+ from: hit.start,
1100
+ to: hit.end,
1101
+ },
1102
+ supportedFixes: [hint],
1103
+ });
1104
+ return message;
1105
+ });
1106
+ }
1107
+
1108
+ export function cleanLabelRouteClearanceProblems({
1109
+ relations,
1110
+ labels,
1111
+ endpointIds,
1112
+ pathFor,
1113
+ diagramType,
1114
+ relationCollection,
1115
+ profile,
1116
+ profileIsAuthoritative = false,
1117
+ threshold = 4,
1118
+ routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other relationship route/via/channel',
1119
+ }) {
1120
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
1121
+ const routedRelations = collectEligibleRoutedRelations({ relations, endpointIds, pathFor });
1122
+ return collectLabelRouteClearance({ labels, routedRelations, threshold }).map((hit) => {
1123
+ const describe = (relation, relationIndex) => {
1124
+ const relationId = relation?.id ? ` id "${relation.id}"` : '';
1125
+ const relationLabel = relation?.label ? ` label "${relation.label}"` : '';
1126
+ return `${relationCollection}[${relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}"${relationLabel}`;
1127
+ };
1128
+ const clearance = Math.round(hit.clearance * 10) / 10;
1129
+ const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
1130
+ const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
1131
+ const hint = rePlanHint([hit.labelRelation, hit.otherRelation], routeHint);
1132
+ const message = `[composition/label-route-clearance] showcase ${diagramType} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${describe(hit.labelRelation, hit.labelRelationIndex)} is ${clearance}px from ${describe(hit.otherRelation, hit.otherRelationIndex)} segment ${hit.segmentIndex} [${from}] -> [${to}] (label rect ${formatRect(hit.rect)}; minimum ${threshold}px) — ${hint}.`;
1133
+ recordDiagnostic({
1134
+ code: 'composition/label-route-clearance',
1135
+ severity: 'error',
1136
+ message,
1137
+ subject: relationshipSubject(diagramType, relationCollection, hit.labelRelationIndex, hit.labelRelation),
1138
+ evidence: {
1139
+ label: hit.label?.label || hit.labelRelation?.label || '',
1140
+ otherRelationship: relationshipSubject(diagramType, relationCollection, hit.otherRelationIndex, hit.otherRelation),
1141
+ segmentIndex: hit.segmentIndex,
1142
+ clearancePx: clearance,
1143
+ minimumPx: threshold,
1144
+ labelRect: hit.rect,
1145
+ from: hit.start,
1146
+ to: hit.end,
1147
+ },
1148
+ supportedFixes: [hint],
1149
+ });
1150
+ return message;
1151
+ });
1152
+ }
1153
+
1154
+ // How far [start, start + size) leaves [0, extent), per end of one axis. This
1155
+ // is the single definition of "inside the canvas" that both the containment
1156
+ // rule and the repair hints measure against; an unknown extent bounds nothing.
1157
+ function axisCanvasOverflow(start, size, extent, origin = 0) {
1158
+ if (!Number.isFinite(extent)) return { start: 0, end: 0 };
1159
+ return { start: origin - start, end: start + size - (origin + extent) };
1160
+ }
1161
+
1162
+ // THE CANVAS CONTAINMENT RATIONALE (referenced from the other call sites).
1163
+ //
1164
+ // The SVG canvas clips whatever leaves the viewBox, so an edge label that
1165
+ // overhangs an edge ships as truncated text while every post-render check on
1166
+ // the emitted markup still passes: clipped text is still well-formed markup.
1167
+ // Renderers with a fixed canvas already bound their nodes, lanes, and legends
1168
+ // against it; edge label rects were the exception.
1169
+ //
1170
+ // Not applied to readable-v2 workflows: that compiler grows its canvas around
1171
+ // pinned label rects and rejects an authored viewBox that cannot hold the
1172
+ // result, so the only gap growth cannot close is the origin its own rule
1173
+ // already guards. Architecture's auto canvas grows the same way, which leaves
1174
+ // this rule reporting authored viewBoxes and the origin side.
1175
+ //
1176
+ // Showcase only: a standard document authored before the rule exists may
1177
+ // overhang by a few pixels, and failing it there would break compatibility
1178
+ // instead of repairing a diagram.
1179
+ export function collectLabelCanvasOverflow({ labels, viewBox, tolerance = 0.5 }) {
1180
+ // Renderers author origin-zero canvases (meta.viewBox is [width, height] by
1181
+ // schema), but `check` re-measures foreign artifacts whose SVG viewBox may
1182
+ // carry a legal non-zero min-x/min-y; those pass all four numbers.
1183
+ const box = asArray(viewBox);
1184
+ const [originX, originY, canvasWidth, canvasHeight] = box.length === 4 ? box : [0, 0, box[0], box[1]];
1185
+ if (!isFinitePoint(originX, originY, canvasWidth, canvasHeight)) return [];
1186
+ const hits = [];
1187
+ for (const [fallbackIndex, label] of asArray(labels).entries()) {
1188
+ const rect = label?.rect || label;
1189
+ if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height)) continue;
1190
+ // `check` parses foreign markup, so a malformed negative-size rect is
1191
+ // skipped like the other label collectors do — its flipped interval would
1192
+ // otherwise read as contained.
1193
+ if (rect.width < 0 || rect.height < 0) continue;
1194
+ const horizontal = axisCanvasOverflow(rect.x, rect.width, canvasWidth, originX);
1195
+ const vertical = axisCanvasOverflow(rect.y, rect.height, canvasHeight, originY);
1196
+ const overflow = {
1197
+ left: horizontal.start,
1198
+ right: horizontal.end,
1199
+ top: vertical.start,
1200
+ bottom: vertical.end,
1201
+ };
1202
+ const sides = Object.keys(overflow).filter((side) => overflow[side] > tolerance);
1203
+ if (!sides.length) continue;
1204
+ hits.push({
1205
+ label,
1206
+ relation: label?.relation,
1207
+ relationIndex: Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex,
1208
+ rect,
1209
+ viewBox: [canvasWidth, canvasHeight],
1210
+ viewBoxOrigin: [originX, originY],
1211
+ sides,
1212
+ overflowPx: Object.fromEntries(sides.map((side) => [side, Math.round(overflow[side] * 10) / 10])),
1213
+ tolerance,
1214
+ });
1215
+ }
1216
+ return hits;
1217
+ }
1218
+
1219
+ export function describeLabelCanvasOverflow(hit) {
1220
+ return hit.sides.map((side) => `${side} edge by ${hit.overflowPx[side]}px`).join(' and ');
1221
+ }
1222
+
1223
+ export function cleanLabelCanvasContainmentProblems({
1224
+ labels,
1225
+ viewBox,
1226
+ diagramType,
1227
+ relationCollection,
1228
+ profile,
1229
+ profileIsAuthoritative = false,
1230
+ routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise enlarge meta.viewBox',
1231
+ // Label widths are estimated from the text, not measured, so sub-pixel
1232
+ // overhang is rounding noise rather than a visible truncation.
1233
+ tolerance = 0.5,
1234
+ }) {
1235
+ if (qualityProfileForGate(profile, profileIsAuthoritative) !== 'showcase') return [];
1236
+ return collectLabelCanvasOverflow({ labels, viewBox, tolerance }).map((hit) => {
1237
+ const relation = hit.relation;
1238
+ const relationId = relation?.id ? ` id "${relation.id}"` : '';
1239
+ const labelText = hit.label?.label || relation?.label || '';
1240
+ const hint = rePlanHint([relation], routeHint);
1241
+ const message = `[composition/label-canvas-containment] showcase ${diagramType} label "${labelText}" on ${relationCollection}[${hit.relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}" extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}) — ${hint}.`;
1242
+ recordDiagnostic({
1243
+ code: 'composition/label-canvas-containment',
1244
+ severity: 'error',
1245
+ message,
1246
+ subject: relationshipSubject(diagramType, relationCollection, hit.relationIndex, relation),
1247
+ evidence: {
1248
+ label: labelText,
1249
+ labelRect: { x: hit.rect.x, y: hit.rect.y, width: hit.rect.width, height: hit.rect.height },
1250
+ viewBox: hit.viewBox,
1251
+ overflowPx: hit.overflowPx,
1252
+ tolerancePx: hit.tolerance,
1253
+ },
1254
+ supportedFixes: [hint],
1255
+ });
1256
+ return message;
1257
+ });
1258
+ }
1259
+
1260
+ function qualityProfileForGate(profile, profileIsAuthoritative) {
1261
+ return profileIsAuthoritative
1262
+ ? profile
1263
+ : process.env.ARCHIFY_QUALITY_PROFILE || profile;
1264
+ }
1265
+
1266
+ function collectEligibleRoutedRelations({ relations, endpointIds, pathFor }) {
1267
+ return asArray(relations).map((relation, relationIndex) => {
1268
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
1269
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
1270
+ return { relation, relationIndex, points: pathFor(relation)?.points };
1271
+ }).filter(Boolean);
1272
+ }
1273
+
1274
+ function segmentPosition(index, segmentCount) {
1275
+ if (index === 0) return 'source-stub';
1276
+ if (index === segmentCount - 1) return 'target-stub';
1277
+ return 'interior';
1278
+ }
1279
+
1280
+ // Normalizing a route is pure, and the same route array is normalized again by
1281
+ // every predicate that inspects it. Keyed by the array itself, so a rebuilt
1282
+ // route simply gets a new entry. The result's outer array is frozen. Callers
1283
+ // must also treat input arrays and their coordinate pairs as immutable, since
1284
+ // edits in place cannot invalidate this identity-based cache.
1285
+ const NORMALIZED_ROUTE_POINTS = new WeakMap();
1286
+
1287
+ // Assembling a route from an anchor, corridor points and an anchor is the same
1288
+ // normalization without the intermediate array, and the result is seeded in the
1289
+ // cache so later predicates do not normalize it again.
1290
+ export function joinRoutePoints(start, via, end) {
1291
+ const normalized = [];
1292
+ const accept = (point) => {
1293
+ if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) return;
1294
+ const previous = normalized.at(-1);
1295
+ if (previous
1296
+ && Math.abs(point[0] - previous[0]) <= 0.0001
1297
+ && Math.abs(point[1] - previous[1]) <= 0.0001) return;
1298
+ while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
1299
+ normalized.push(point);
1300
+ };
1301
+ accept(start);
1302
+ for (const point of Array.isArray(via) ? via : []) accept(point);
1303
+ accept(end);
1304
+ Object.freeze(normalized);
1305
+ NORMALIZED_ROUTE_POINTS.set(normalized, normalized);
1306
+ return normalized;
1307
+ }
1308
+
1309
+ export function normalizeRoutePoints(points) {
1310
+ if (Array.isArray(points)) {
1311
+ const cached = NORMALIZED_ROUTE_POINTS.get(points);
1312
+ if (cached) return cached;
1313
+ }
1314
+ const normalized = [];
1315
+ for (const point of Array.isArray(points) ? points : []) {
1316
+ if (!Array.isArray(point) || point.length !== 2 || !isFinitePoint(point[0], point[1])) continue;
1317
+ const previous = normalized.at(-1);
1318
+ if (previous
1319
+ && Math.abs(point[0] - previous[0]) <= 0.0001
1320
+ && Math.abs(point[1] - previous[1]) <= 0.0001) continue;
1321
+ while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
1322
+ normalized.push(point);
1323
+ }
1324
+ if (Array.isArray(points)) {
1325
+ Object.freeze(normalized);
1326
+ NORMALIZED_ROUTE_POINTS.set(points, normalized);
1327
+ }
1328
+ return normalized;
1329
+ }
1330
+
1331
+ function pointRectDistance(point, rect) {
1332
+ const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
1333
+ const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
1334
+ return Math.hypot(dx, dy);
1335
+ }
1336
+
1337
+ function pointSegmentDistanceXY(px, py, start, end) {
1338
+ const dx = end[0] - start[0];
1339
+ const dy = end[1] - start[1];
1340
+ const lengthSquared = dx * dx + dy * dy;
1341
+ if (lengthSquared <= 0.0000001) return Math.hypot(px - start[0], py - start[1]);
1342
+ const t = Math.max(0, Math.min(1, ((px - start[0]) * dx + (py - start[1]) * dy) / lengthSquared));
1343
+ return Math.hypot(px - (start[0] + t * dx), py - (start[1] + t * dy));
1344
+ }
1345
+
1346
+ function pointSegmentDistance(point, start, end) {
1347
+ return pointSegmentDistanceXY(point[0], point[1], start, end);
1348
+ }
1349
+
1350
+ function collinearForward(a, b, c) {
1351
+ if (Math.abs(crossProduct(a, b, c)) > 0.0001) return false;
1352
+ return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001;
1353
+ }
1354
+
1355
+ export function frameBorderSegments(frame) {
1356
+ if (!frame || typeof frame !== 'object') return [];
1357
+ if (frame.shape === 'line') {
1358
+ const start = frame.start || [frame.x1, frame.y1];
1359
+ const end = frame.end || [frame.x2, frame.y2];
1360
+ return isFinitePoint(...start, ...end) ? [{ side: 'line', start, end }] : [];
1361
+ }
1362
+ if (!isFinitePoint(frame.x, frame.y, frame.width, frame.height) || frame.width <= 0 || frame.height <= 0) return [];
1363
+ const radius = Math.max(0, Math.min(Number(frame.radius) || 0, frame.width / 2, frame.height / 2));
1364
+ const left = frame.x;
1365
+ const right = frame.x + frame.width;
1366
+ const top = frame.y;
1367
+ const bottom = frame.y + frame.height;
1368
+ return [
1369
+ { side: 'top', start: [left + radius, top], end: [right - radius, top] },
1370
+ { side: 'right', start: [right, top + radius], end: [right, bottom - radius] },
1371
+ { side: 'bottom', start: [right - radius, bottom], end: [left + radius, bottom] },
1372
+ { side: 'left', start: [left, bottom - radius], end: [left, top + radius] },
1373
+ ].filter(({ start, end }) => Math.hypot(end[0] - start[0], end[1] - start[1]) > 0.0001);
1374
+ }
1375
+
1376
+ function mergeBorderOverlaps(overlaps, border) {
1377
+ const horizontal = Math.abs(border.start[1] - border.end[1]) <= 0.0001;
1378
+ const axis = horizontal ? 0 : 1;
1379
+ const fixed = horizontal ? border.start[1] : border.start[0];
1380
+ const sorted = overlaps.map((overlap) => ({
1381
+ low: Math.min(overlap.start[axis], overlap.end[axis]),
1382
+ high: Math.max(overlap.start[axis], overlap.end[axis]),
1383
+ })).sort((left, right) => left.low - right.low || left.high - right.high);
1384
+ const merged = [];
1385
+ for (const interval of sorted) {
1386
+ const previous = merged.at(-1);
1387
+ if (previous && interval.low <= previous.high + 0.0001) previous.high = Math.max(previous.high, interval.high);
1388
+ else merged.push({ ...interval });
1389
+ }
1390
+ return merged.map((interval) => ({
1391
+ ...interval,
1392
+ length: interval.high - interval.low,
1393
+ start: horizontal ? [interval.low, fixed] : [fixed, interval.low],
1394
+ end: horizontal ? [interval.high, fixed] : [fixed, interval.high],
1395
+ }));
1396
+ }
1397
+
1398
+ function collinearAxisOverlap(a, b, c, d) {
1399
+ const epsilon = 0.0001;
1400
+ const horizontal = Math.abs(a[1] - b[1]) <= epsilon
1401
+ && Math.abs(c[1] - d[1]) <= epsilon
1402
+ && Math.abs(a[1] - c[1]) <= epsilon;
1403
+ const vertical = Math.abs(a[0] - b[0]) <= epsilon
1404
+ && Math.abs(c[0] - d[0]) <= epsilon
1405
+ && Math.abs(a[0] - c[0]) <= epsilon;
1406
+ if (!horizontal && !vertical) return null;
1407
+ const axis = horizontal ? 0 : 1;
1408
+ const low = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
1409
+ const high = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]));
1410
+ if (high - low <= epsilon) return null;
1411
+ const fixed = horizontal ? a[1] : a[0];
1412
+ return {
1413
+ length: high - low,
1414
+ start: horizontal ? [low, fixed] : [fixed, low],
1415
+ end: horizontal ? [high, fixed] : [fixed, high],
1416
+ };
1417
+ }
1418
+
1419
+ export function properSegmentIntersection(a, b, c, d) {
1420
+ const abC = crossProduct(a, b, c);
1421
+ const abD = crossProduct(a, b, d);
1422
+ const cdA = crossProduct(c, d, a);
1423
+ const cdB = crossProduct(c, d, b);
1424
+ const epsilon = 0.0001;
1425
+ const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
1426
+ if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
1427
+
1428
+ const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
1429
+ if (Math.abs(denominator) < epsilon) return null;
1430
+ const ab = a[0] * b[1] - a[1] * b[0];
1431
+ const cd = c[0] * d[1] - c[1] * d[0];
1432
+ return [
1433
+ (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
1434
+ (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator
1435
+ ];
1436
+ }
1437
+
1438
+ function crossProduct(a, b, c) {
1439
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1440
+ }
1441
+
1442
+ function pointInBox(point, box) {
1443
+ return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
1444
+ }
1445
+
1446
+ function segmentsIntersect(a, b, c, d) {
1447
+ const o1 = orientation(a, b, c);
1448
+ const o2 = orientation(a, b, d);
1449
+ const o3 = orientation(c, d, a);
1450
+ const o4 = orientation(c, d, b);
1451
+
1452
+ if (o1 === 0 && onSegment(a, c, b)) return true;
1453
+ if (o2 === 0 && onSegment(a, d, b)) return true;
1454
+ if (o3 === 0 && onSegment(c, a, d)) return true;
1455
+ if (o4 === 0 && onSegment(c, b, d)) return true;
1456
+
1457
+ return o1 !== o2 && o3 !== o4;
1458
+ }
1459
+
1460
+ function orientation(a, b, c) {
1461
+ const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
1462
+ if (Math.abs(value) < 0.0001) return 0;
1463
+ return value > 0 ? 1 : 2;
1464
+ }
1465
+
1466
+ function onSegment(a, b, c) {
1467
+ return (
1468
+ b[0] <= Math.max(a[0], c[0]) &&
1469
+ b[0] >= Math.min(a[0], c[0]) &&
1470
+ b[1] <= Math.max(a[1], c[1]) &&
1471
+ b[1] >= Math.min(a[1], c[1])
1472
+ );
1473
+ }
1474
+
1475
+ export function anchor(rect, side) {
1476
+ switch (side) {
1477
+ case 'left': return [rect.x, rect.cy];
1478
+ case 'right': return [rect.x + rect.width, rect.cy];
1479
+ case 'top': return [rect.cx, rect.y];
1480
+ case 'bottom': return [rect.cx, rect.y + rect.height];
1481
+ default:
1482
+ return [rect.x + rect.width, rect.cy];
1483
+ }
1484
+ }
1485
+
1486
+ const PORT_OUTWARD_VECTOR = {
1487
+ left: [-1, 0],
1488
+ right: [1, 0],
1489
+ top: [0, -1],
1490
+ bottom: [0, 1],
1491
+ };
1492
+
1493
+ // Automatic port spreading can put otherwise parallel anchors only a few
1494
+ // pixels apart. A conventional midpoint dogleg then violates the renderer's
1495
+ // own 8px/16px route-rhythm floors. Return a full outside-channel route when
1496
+ // that happens, or null when the normal automatic route remains appropriate.
1497
+ export function automaticPortRhythmBridge(
1498
+ start,
1499
+ end,
1500
+ fromSide,
1501
+ toSide,
1502
+ { endpointStubPx = 24, interiorSegmentPx = 16, accept } = {},
1503
+ ) {
1504
+ if (!Array.isArray(start) || !Array.isArray(end)
1505
+ || start.length !== 2 || end.length !== 2
1506
+ || !isFinitePoint(...start, ...end)) return null;
1507
+ const fromVector = PORT_OUTWARD_VECTOR[fromSide];
1508
+ const toVector = PORT_OUTWARD_VECTOR[toSide];
1509
+ if (!fromVector || !toVector) return null;
1510
+
1511
+ const opposedFacingGap = (
1512
+ (fromSide === 'right' && toSide === 'left' && end[0] > start[0])
1513
+ || (fromSide === 'left' && toSide === 'right' && start[0] > end[0])
1514
+ ) ? Math.abs(end[0] - start[0]) : (
1515
+ (fromSide === 'bottom' && toSide === 'top' && end[1] > start[1])
1516
+ || (fromSide === 'top' && toSide === 'bottom' && start[1] > end[1])
1517
+ ) ? Math.abs(end[1] - start[1]) : null;
1518
+ const boundedStubPx = opposedFacingGap != null
1519
+ && opposedFacingGap - endpointStubPx * 2 < interiorSegmentPx
1520
+ ? Math.max(8, Math.min(endpointStubPx, (opposedFacingGap - interiorSegmentPx) / 2))
1521
+ : null;
1522
+ const stubDistances = [endpointStubPx];
1523
+ if (boundedStubPx != null && boundedStubPx >= 8 && boundedStubPx !== endpointStubPx) {
1524
+ stubDistances.push(boundedStubPx);
1525
+ }
1526
+ const candidates = [];
1527
+ const verticalSides = new Set(['top', 'bottom']);
1528
+ const horizontalSides = new Set(['left', 'right']);
1529
+
1530
+ for (const stubPx of stubDistances) {
1531
+ const startStub = [
1532
+ start[0] + fromVector[0] * stubPx,
1533
+ start[1] + fromVector[1] * stubPx,
1534
+ ];
1535
+ const endStub = [
1536
+ end[0] + toVector[0] * stubPx,
1537
+ end[1] + toVector[1] * stubPx,
1538
+ ];
1539
+ if (verticalSides.has(fromSide) && verticalSides.has(toSide)
1540
+ && Math.abs(start[0] - end[0]) < interiorSegmentPx) {
1541
+ for (const channelX of [
1542
+ Math.max(start[0], end[0]) + interiorSegmentPx,
1543
+ Math.min(start[0], end[0]) - interiorSegmentPx,
1544
+ ]) {
1545
+ candidates.push([
1546
+ start,
1547
+ startStub,
1548
+ [channelX, startStub[1]],
1549
+ [channelX, endStub[1]],
1550
+ endStub,
1551
+ end,
1552
+ ]);
1553
+ }
1554
+ }
1555
+ if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
1556
+ && Math.abs(start[1] - end[1]) < interiorSegmentPx) {
1557
+ for (const channelY of [
1558
+ Math.max(start[1], end[1]) + interiorSegmentPx,
1559
+ Math.min(start[1], end[1]) - interiorSegmentPx,
1560
+ ]) {
1561
+ candidates.push([
1562
+ start,
1563
+ startStub,
1564
+ [startStub[0], channelY],
1565
+ [endStub[0], channelY],
1566
+ endStub,
1567
+ end,
1568
+ ]);
1569
+ }
1570
+ }
1571
+ }
1572
+
1573
+ return candidates
1574
+ .map((points) => normalizeRoutePoints(points))
1575
+ .find((points) => (
1576
+ routeHonorsEndpointSides(points, fromSide, toSide)
1577
+ && collectRouteRhythmIssues({ routedRelations: [{ points }], interiorSegmentPx }).length === 0
1578
+ && (typeof accept !== 'function' || accept(points))
1579
+ )) || null;
1580
+ }
1581
+
1582
+ // Keep conservative auto-routed fan-out/fan-in relationships visually
1583
+ // distinct without changing authored route controls. The returned map only
1584
+ // contains endpoints that belong to a shared automatic midpoint anchor.
1585
+ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14, sideFor, spacingFor } = {}) {
1586
+ const groups = new Map();
1587
+ const spread = new Map();
1588
+
1589
+ const add = (relation, endpoint, rect, side, counterpart) => {
1590
+ const key = `${rect.id}\u0000${side}`;
1591
+ const items = groups.get(key) || [];
1592
+ items.push({ relation, endpoint, rect, side, counterpart });
1593
+ groups.set(key, items);
1594
+ };
1595
+
1596
+ for (const relation of asArray(relations)) {
1597
+ if (!relation || (relation.route && relation.route !== 'auto')) continue;
1598
+ if (relation.via || relation.channelX !== undefined || relation.channelY !== undefined || relation.labelAt) continue;
1599
+ const from = boxes.get(relation.from);
1600
+ const to = boxes.get(relation.to);
1601
+ if (!from || !to) continue;
1602
+ const fromSide = chosenSide(
1603
+ relation.fromSide,
1604
+ sideFor?.(relation, 'source') || defaultFromSide(from, to),
1605
+ );
1606
+ const toSide = chosenSide(
1607
+ relation.toSide,
1608
+ sideFor?.(relation, 'target') || defaultToSide(from, to),
1609
+ );
1610
+ add(relation, 'from', from, fromSide, to);
1611
+ add(relation, 'to', to, toSide, from);
1612
+ }
1613
+
1614
+ for (const items of groups.values()) {
1615
+ if (items.length < 2) continue;
1616
+ const verticalSide = items[0].side === 'left' || items[0].side === 'right';
1617
+ items.sort((a, b) => {
1618
+ const aCoordinate = verticalSide ? a.counterpart.cy : a.counterpart.cx;
1619
+ const bCoordinate = verticalSide ? b.counterpart.cy : b.counterpart.cx;
1620
+ if (aCoordinate !== bCoordinate) return aCoordinate - bCoordinate;
1621
+ const aKey = `${a.relation.id || ''}\u0000${a.relation.from}\u0000${a.relation.to}\u0000${a.relation.label || ''}`;
1622
+ const bKey = `${b.relation.id || ''}\u0000${b.relation.from}\u0000${b.relation.to}\u0000${b.relation.label || ''}`;
1623
+ return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
1624
+ });
1625
+
1626
+ const extent = verticalSide ? items[0].rect.height : items[0].rect.width;
1627
+ const usable = Math.max(0, extent - gutter * 2);
1628
+ const spacing = Math.min(maxSpacing, usable / (items.length - 1));
1629
+ if (!(spacing > 0)) continue;
1630
+
1631
+ // Width-aware callers reserve the whole group together. Moving a single
1632
+ // port around the legacy 14px slots can wrongly report a full side while
1633
+ // its still-unrouted neighbours could have fitted farther apart.
1634
+ let offsets;
1635
+ if (spacingFor) {
1636
+ const gaps = items.slice(1).map((item, index) => Math.max(maxSpacing,
1637
+ spacingFor(items[index].relation, item.relation)));
1638
+ const span = gaps.reduce((sum, gap) => sum + gap, 0);
1639
+ if (span <= usable && gaps.some((gap) => gap > maxSpacing)) {
1640
+ let offset = -span / 2;
1641
+ offsets = [offset, ...gaps.map((gap) => (offset += gap))];
1642
+ }
1643
+ }
1644
+
1645
+ for (const [index, item] of items.entries()) {
1646
+ const offset = offsets?.[index] ?? (index - (items.length - 1) / 2) * spacing;
1647
+ const point = anchor(item.rect, item.side);
1648
+ if (verticalSide) point[1] += offset;
1649
+ else point[0] += offset;
1650
+ const endpoints = spread.get(item.relation) || {};
1651
+ endpoints[item.endpoint] = point;
1652
+ spread.set(item.relation, endpoints);
1653
+ }
1654
+ }
1655
+
1656
+ return spread;
1657
+ }
1658
+
1659
+ // A centre delta on the horizontal axis does not by itself mean the route
1660
+ // leaves sideways: a hub above an offset spoke has both a horizontal and a
1661
+ // larger vertical delta, and the router draws the vertical dogleg. Compare the
1662
+ // deltas so the inferred side matches the axis the route actually uses. Equal
1663
+ // deltas select horizontal sides; dx === 0 preserves the previous vertical
1664
+ // result, while a vertical-dominant nonzero dx selects vertical sides.
1665
+ export function defaultFromSide(from, to) {
1666
+ const dx = to.cx - from.cx;
1667
+ const dy = to.cy - from.cy;
1668
+ if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'left' : 'right';
1669
+ return dy > 0 ? 'bottom' : 'top';
1670
+ }
1671
+
1672
+ export function defaultToSide(from, to) {
1673
+ const dx = to.cx - from.cx;
1674
+ const dy = to.cy - from.cy;
1675
+ if (dx !== 0 && Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? 'right' : 'left';
1676
+ return dy > 0 ? 'top' : 'bottom';
1677
+ }
1678
+
1679
+ // Non-architecture renderers retain their established horizontal-first
1680
+ // endpoint contract. Architecture opts into dominant-axis inference above so
1681
+ // a vertical hub-and-spoke route can use perpendicular automatic ports.
1682
+ export function legacyDefaultFromSide(from, to) {
1683
+ if (to.cx < from.cx) return 'left';
1684
+ if (to.cx > from.cx) return 'right';
1685
+ if (to.cy > from.cy) return 'bottom';
1686
+ return 'top';
1687
+ }
1688
+
1689
+ export function legacyDefaultToSide(from, to) {
1690
+ if (to.cx < from.cx) return 'right';
1691
+ if (to.cx > from.cx) return 'left';
1692
+ if (to.cy > from.cy) return 'top';
1693
+ return 'bottom';
1694
+ }
1695
+
1696
+ export function chosenSide(side, fallback) {
1697
+ return side && side !== 'auto' ? side : fallback;
1698
+ }
1699
+
1700
+ export function polylinePath(points) {
1701
+ return points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
1702
+ }
1703
+
1704
+ export function routePointsValue(points) {
1705
+ return asArray(points)
1706
+ .filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))
1707
+ .map(([x, y]) => `${x},${y}`)
1708
+ .join(';');
1709
+ }
1710
+
1711
+ // Only a direct, explicitly authored diagonal needs an artifact-check exception.
1712
+ // Nonempty via takes precedence; empty via adds no intermediate geometry.
1713
+ export function authoredStraightRouteAttrs(relation, points) {
1714
+ if (relation.route !== 'straight' || relation.via?.length || points.length !== 2) return '';
1715
+ const [start, end] = points;
1716
+ return Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
1717
+ ? ' data-composition-route="straight"'
1718
+ : '';
1719
+ }
1720
+
1721
+ export function roundedPath(points, radius) {
1722
+ if (points.length < 3 || radius <= 0) {
1723
+ return polylinePath(points);
1724
+ }
1725
+
1726
+ const commands = [`M ${points[0][0]} ${points[0][1]}`];
1727
+ for (let i = 1; i < points.length - 1; i += 1) {
1728
+ const [px, py] = points[i - 1];
1729
+ const [cx, cy] = points[i];
1730
+ const [nx, ny] = points[i + 1];
1731
+ const prevLen = Math.hypot(cx - px, cy - py);
1732
+ const nextLen = Math.hypot(nx - cx, ny - cy);
1733
+ const r = Math.min(radius, prevLen / 2, nextLen / 2);
1734
+ if (r < 1) {
1735
+ commands.push(`L ${cx} ${cy}`);
1736
+ continue;
1737
+ }
1738
+ const before = [cx - ((cx - px) / prevLen) * r, cy - ((cy - py) / prevLen) * r];
1739
+ const after = [cx + ((nx - cx) / nextLen) * r, cy + ((ny - cy) / nextLen) * r];
1740
+ commands.push(`L ${before[0]} ${before[1]}`);
1741
+ commands.push(`Q ${cx} ${cy} ${after[0]} ${after[1]}`);
1742
+ }
1743
+ const [endX, endY] = points[points.length - 1];
1744
+ commands.push(`L ${endX} ${endY}`);
1745
+ return commands.join(' ');
1746
+ }
1747
+
1748
+ // Shared by edges/flows/transitions: all carry the same optional
1749
+ // labelAt/labelDx/labelDy/labelSegment knobs.
1750
+ export function labelPoint(item, points) {
1751
+ if (item.labelAt) return item.labelAt;
1752
+ if (points.length === 2) {
1753
+ return [
1754
+ (points[0][0] + points[1][0]) / 2 + (item.labelDx || 0),
1755
+ points[0][1] - 10 + (item.labelDy || 0)
1756
+ ];
1757
+ }
1758
+ const segmentIndex = Math.min(points.length - 2, Math.max(0, item.labelSegment ?? 1));
1759
+ const a = points[segmentIndex];
1760
+ const b = points[segmentIndex + 1];
1761
+ return [(a[0] + b[0]) / 2 + (item.labelDx || 0), (a[1] + b[1]) / 2 - 10 + (item.labelDy || 0)];
1762
+ }
1763
+
1764
+ export const componentFill = {
1765
+ frontend: 'c-frontend',
1766
+ backend: 'c-backend',
1767
+ database: 'c-database',
1768
+ cloud: 'c-cloud',
1769
+ security: 'c-security',
1770
+ messagebus: 'c-messagebus',
1771
+ external: 'c-external'
1772
+ };
1773
+
1774
+ export const componentText = {
1775
+ frontend: 't-frontend',
1776
+ backend: 't-backend',
1777
+ database: 't-database',
1778
+ cloud: 't-cloud',
1779
+ security: 't-security',
1780
+ messagebus: 't-messagebus',
1781
+ external: 't-external'
1782
+ };
1783
+
1784
+ export const arrowClassMap = {
1785
+ default: ['a-default', 'arrowhead'],
1786
+ emphasis: ['a-emphasis', 'arrowhead-emphasis'],
1787
+ security: ['a-security', 'arrowhead-security'],
1788
+ dashed: ['a-dashed', 'arrowhead-dashed']
1789
+ };
1790
+
1791
+ // Structural phase/group accents retain their existing semantic colors.
1792
+ export function variantAccent(variant) {
1793
+ return variant === 'security' ? 't-security'
1794
+ : variant === 'emphasis' ? 't-backend'
1795
+ : variant === 'dashed' ? 't-messagebus' : 't-muted';
1796
+ }
1797
+
1798
+ // Relationship labels use the same theme token as their path. Keep this map
1799
+ // edge-specific: node-kind text colors only coincide with some path colors in
1800
+ // the classic preset and must not define the relationship's visual meaning.
1801
+ export function edgeLabelAccent(variant) {
1802
+ return variant === 'security'
1803
+ ? 't-edge-security'
1804
+ : variant === 'emphasis'
1805
+ ? 't-edge-emphasis'
1806
+ : variant === 'dashed'
1807
+ ? 't-edge-dashed'
1808
+ : 't-edge-default';
1809
+ }
1810
+
1811
+ export function formatRect(r) {
1812
+ return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`;
1813
+ }
1814
+
1815
+ function formatValue(n) {
1816
+ return String(Math.round(n));
1817
+ }
1818
+
1819
+ // A label anchor is the text origin, so keeping a suggestion on the canvas
1820
+ // means moving the whole rect: offset is `rect - anchor` on that axis.
1821
+ function anchorFitsCanvas(anchorValue, offset, size, extent) {
1822
+ const overflow = axisCanvasOverflow(anchorValue + offset, size, extent);
1823
+ return overflow.start <= 0 && overflow.end <= 0;
1824
+ }
1825
+
1826
+ // Suggestions are emitted as integers, so the real-valued bounds are tightened
1827
+ // inward with ceil/floor first: every integer between them keeps the whole rect
1828
+ // on the canvas, which a bound like 616.7 would not. Returns null when the rect
1829
+ // is wider than the canvas and no anchor can contain it.
1830
+ function clampAnchorToCanvas(anchorValue, offset, size, extent) {
1831
+ const rounded = Math.round(anchorValue);
1832
+ if (!Number.isFinite(extent)) return rounded;
1833
+ const min = Math.ceil(-offset);
1834
+ const max = Math.floor(extent - size - offset);
1835
+ return max < min ? null : Math.min(Math.max(rounded, min), max);
1836
+ }
1837
+
1838
+ /**
1839
+ * Actionable hint when an edge label rect hits a node/component box (#7).
1840
+ * Every suggested value is a replacement for the authored field, not an
1841
+ * increment, and has to survive being applied to the document:
1842
+ * - the absolute form is nudged along x so the rect stays on the canvas, and a
1843
+ * vertical placement that cannot fit is dropped rather than clamped, since
1844
+ * clamping it back would push the label onto the obstacle it must clear;
1845
+ * - the relative form is offered only when labelDx/labelDy actually move this
1846
+ * label (labelPoint returns an authored labelAt as-is) and only when the
1847
+ * replacements, computed from the document's own labelDx/labelDy, land the
1848
+ * rect inside the canvas.
1849
+ * The authored values therefore come from `labelRect.relation`, the authored
1850
+ * relationship the renderer already attaches to every label record.
1851
+ * `obstacles` is every box the callers' own overlap loop tests (defaults to
1852
+ * the named obstacle alone): a placement that merely traded the named obstacle
1853
+ * for its neighbor would fail the same rule again when applied.
1854
+ */
1855
+ export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component', viewBox, obstacles) {
1856
+ // Renderer canvases are origin-zero [width, height] by schema, but read a
1857
+ // 4-number [min-x, min-y, width, height] the same way the containment
1858
+ // collector does — degraded no-ajv runs must not clamp against the origin
1859
+ // pair as if it were the canvas size.
1860
+ const box = asArray(viewBox);
1861
+ const [canvasWidth, canvasHeight] = box.length === 4 ? box.slice(2) : box;
1862
+ const xOffset = labelRect.x - lx;
1863
+ const yOffset = labelRect.y - ly;
1864
+ const anchorX = clampAnchorToCanvas(lx, xOffset, labelRect.width, canvasWidth);
1865
+ // The placement anchors are derived from the label's own rect, not from the
1866
+ // obstacle alone: an obstacle-only "above" anchor assumes the 14px
1867
+ // single-line rect and lands a 27px two-line rect (dataflow classification,
1868
+ // lifecycle note) back on the obstacle it must clear. The overlap filter
1869
+ // uses the callers' own detection call, so a surviving hint cannot re-raise
1870
+ // the problem it repairs.
1871
+ const blockers = obstacles ? [...obstacles] : [obstacle];
1872
+ const placements = [
1873
+ { name: 'below', y: Math.round(obstacle.y + obstacle.height + 4 - yOffset) },
1874
+ { name: 'above', y: Math.round(obstacle.y - 4 - labelRect.height - yOffset) },
1875
+ ].filter(({ y }) => anchorX !== null
1876
+ && anchorFitsCanvas(y, yOffset, labelRect.height, canvasHeight)
1877
+ && blockers.every((blocker) => !rectsOverlap(
1878
+ { x: anchorX + xOffset, y: y + yOffset, width: labelRect.width, height: labelRect.height },
1879
+ blocker,
1880
+ -2,
1881
+ )));
1882
+ const lines = [
1883
+ ` label rect: ${formatRect(labelRect)}`,
1884
+ ` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`,
1885
+ ];
1886
+ if (!placements.length) {
1887
+ // Only the vertical slots beside the named obstacle were tried, so an
1888
+ // unclaimed spot elsewhere may still exist — never assert that none does.
1889
+ lines.push(anchorX === null
1890
+ ? ` Suggested fix: the ${Math.round(labelRect.width)}px label rect cannot fit the ${canvasWidth}x${canvasHeight} viewBox at any anchor — shorten the label or enlarge meta.viewBox`
1891
+ : ` Suggested fix: no placement directly above or below "${obstacle.id}" stays clear inside the ${canvasWidth}x${canvasHeight} viewBox — move the label to an open area with labelAt, shorten the label, move the ${obstacleKind}, or enlarge meta.viewBox`);
1892
+ return lines.join('\n');
1893
+ }
1894
+ const authored = labelRect.relation || {};
1895
+ const authoredDx = Number.isFinite(authored.labelDx) ? authored.labelDx : 0;
1896
+ const authoredDy = Number.isFinite(authored.labelDy) ? authored.labelDy : 0;
1897
+ const relativeHint = (y) => {
1898
+ if (Array.isArray(authored.labelAt)) return null;
1899
+ // Both replacements are measured from the automatic label point that the
1900
+ // authored offsets are applied to, and against the unrounded anchor: a
1901
+ // rounded base would shift the applied label by up to a pixel.
1902
+ const dx = Math.round(authoredDx + anchorX - lx);
1903
+ const dy = Math.round(authoredDy + y - ly);
1904
+ // labelDx is authored as a number, so an authored fraction must not read as
1905
+ // a needed nudge once the replacement is rounded to an integer.
1906
+ const movesX = Math.abs(dx - authoredDx) >= 0.5;
1907
+ const landsAtX = lx - authoredDx + (movesX ? dx : authoredDx);
1908
+ const landsAtY = ly - authoredDy + dy;
1909
+ if (!anchorFitsCanvas(landsAtX, xOffset, labelRect.width, canvasWidth)) return null;
1910
+ if (!anchorFitsCanvas(landsAtY, yOffset, labelRect.height, canvasHeight)) return null;
1911
+ return movesX
1912
+ ? `set labelDx ${formatValue(dx)} with labelDy ${formatValue(dy)}`
1913
+ : `set labelDy ${formatValue(dy)}`;
1914
+ };
1915
+ const hint = placements
1916
+ .map(({ name, y }) => {
1917
+ const relative = relativeHint(y);
1918
+ return `set labelAt [${anchorX}, ${y}]${relative ? ` or ${relative}` : ''} (${name})`;
1919
+ })
1920
+ .join('; or ');
1921
+ lines.push(` Suggested fix: ${hint}`);
1922
+ return lines.join('\n');
1923
+ }
1924
+
1925
+ /** Hint when two edge labels collide. */
1926
+ export function suggestLabelPairFix(a, b) {
1927
+ return [
1928
+ ` "${a.label}" ${formatRect(a)}; "${b.label}" ${formatRect(b)}`,
1929
+ ' Suggested fix: adjust labelDx/labelDy/labelSegment, or route one relationship through a separate corridor',
1930
+ ].join('\n');
1931
+ }
1932
+
1933
+ /** Hint when two components/nodes are too close. */
1934
+ export function suggestComponentSeparation(a, b, minGap = 8) {
1935
+ const rightX = Math.round(a.x + a.width + minGap);
1936
+ const belowY = Math.round(a.y + a.height + minGap);
1937
+ return [
1938
+ ` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`,
1939
+ ` Suggested fix: move "${b.id}" pos to [${rightX}, ${Math.round(b.y)}] (right of "${a.id}") or [${Math.round(b.x)}, ${belowY}] (below)`,
1940
+ ].join('\n');
1941
+ }