arkaos 4.32.0 → 4.34.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 (92) hide show
  1. package/README.md +1 -1
  2. package/THE-ARKAOS-GUIDE.md +1 -1
  3. package/VERSION +1 -1
  4. package/arka/SKILL.md +1 -1
  5. package/arka/skills/forge/references/workflows.md +10 -0
  6. package/config/claude-agents/architect.md +1 -1
  7. package/config/claude-agents/backend-dev.md +1 -1
  8. package/config/claude-agents/devops-eng.md +1 -1
  9. package/config/claude-agents/frontend-dev.md +1 -1
  10. package/config/claude-agents/ops-lead.md +1 -1
  11. package/config/claude-agents/pm-director.md +1 -1
  12. package/config/claude-agents/strategy-director.md +1 -1
  13. package/config/hooks/session-start.sh +1 -1
  14. package/config/skills-curated.yaml +2 -2
  15. package/config/skills-provenance.yaml +7 -0
  16. package/config/statusline.sh +6 -1
  17. package/core/hooks/session_start.py +69 -28
  18. package/departments/dev/agents/architect.yaml +1 -0
  19. package/departments/dev/agents/backend-dev.yaml +1 -0
  20. package/departments/dev/agents/devops-eng.yaml +1 -0
  21. package/departments/dev/agents/frontend-dev.yaml +1 -0
  22. package/departments/dev/agents/tech-lead.yaml +1 -0
  23. package/departments/dev/skills/diagram/SKILL.md +175 -0
  24. package/departments/dev/skills/diagram/examples/archify-repo-grid.architecture.json +57 -0
  25. package/departments/dev/skills/diagram/vendor/LICENSE +22 -0
  26. package/departments/dev/skills/diagram/vendor/SKILL.md +367 -0
  27. package/departments/dev/skills/diagram/vendor/assets/template.html +11470 -0
  28. package/departments/dev/skills/diagram/vendor/bin/archify.mjs +600 -0
  29. package/departments/dev/skills/diagram/vendor/bin/open-artifact.mjs +67 -0
  30. package/departments/dev/skills/diagram/vendor/examples/agent-run.lifecycle.json +71 -0
  31. package/departments/dev/skills/diagram/vendor/examples/agent-tool-call.workflow.json +111 -0
  32. package/departments/dev/skills/diagram/vendor/examples/async-job-roundtrip.sequence.json +62 -0
  33. package/departments/dev/skills/diagram/vendor/examples/cache-miss-request.sequence.json +82 -0
  34. package/departments/dev/skills/diagram/vendor/examples/deployment-release.lifecycle.json +50 -0
  35. package/departments/dev/skills/diagram/vendor/examples/event-stream.dataflow.json +58 -0
  36. package/departments/dev/skills/diagram/vendor/examples/incident-response.workflow.json +65 -0
  37. package/departments/dev/skills/diagram/vendor/examples/product-analytics.dataflow.json +77 -0
  38. package/departments/dev/skills/diagram/vendor/examples/production-deployment.architecture.json +70 -0
  39. package/departments/dev/skills/diagram/vendor/examples/release-delivery.workflow.json +63 -0
  40. package/departments/dev/skills/diagram/vendor/examples/web-app.architecture.json +47 -0
  41. package/departments/dev/skills/diagram/vendor/package-lock.json +80 -0
  42. package/departments/dev/skills/diagram/vendor/package.json +28 -0
  43. package/departments/dev/skills/diagram/vendor/recipes/scenarios.mjs +350 -0
  44. package/departments/dev/skills/diagram/vendor/renderers/architecture/grid.mjs +62 -0
  45. package/departments/dev/skills/diagram/vendor/renderers/architecture/render-architecture.mjs +490 -0
  46. package/departments/dev/skills/diagram/vendor/renderers/dataflow/README.md +93 -0
  47. package/departments/dev/skills/diagram/vendor/renderers/dataflow/render-dataflow.mjs +396 -0
  48. package/departments/dev/skills/diagram/vendor/renderers/lifecycle/README.md +103 -0
  49. package/departments/dev/skills/diagram/vendor/renderers/lifecycle/render-lifecycle.mjs +482 -0
  50. package/departments/dev/skills/diagram/vendor/renderers/sequence/README.md +96 -0
  51. package/departments/dev/skills/diagram/vendor/renderers/sequence/render-sequence.mjs +357 -0
  52. package/departments/dev/skills/diagram/vendor/renderers/shared/cli.mjs +176 -0
  53. package/departments/dev/skills/diagram/vendor/renderers/shared/generated-validators.mjs +13 -0
  54. package/departments/dev/skills/diagram/vendor/renderers/shared/geometry.mjs +992 -0
  55. package/departments/dev/skills/diagram/vendor/renderers/shared/layout-report.mjs +40 -0
  56. package/departments/dev/skills/diagram/vendor/renderers/shared/utils.mjs +156 -0
  57. package/departments/dev/skills/diagram/vendor/renderers/shared/validator.mjs +40 -0
  58. package/departments/dev/skills/diagram/vendor/renderers/workflow/README.md +114 -0
  59. package/departments/dev/skills/diagram/vendor/renderers/workflow/render-workflow.mjs +598 -0
  60. package/departments/dev/skills/diagram/vendor/schemas/README.md +93 -0
  61. package/departments/dev/skills/diagram/vendor/schemas/architecture.schema.json +125 -0
  62. package/departments/dev/skills/diagram/vendor/schemas/common.schema.json +62 -0
  63. package/departments/dev/skills/diagram/vendor/schemas/dataflow.schema.json +240 -0
  64. package/departments/dev/skills/diagram/vendor/schemas/lifecycle.schema.json +260 -0
  65. package/departments/dev/skills/diagram/vendor/schemas/sequence.schema.json +205 -0
  66. package/departments/dev/skills/diagram/vendor/schemas/workflow.schema.json +351 -0
  67. package/departments/dev/skills/diagram/vendor/scripts/check-render-output.mjs +774 -0
  68. package/departments/dev/skills/diagram/vendor/scripts/generate-validators.mjs +66 -0
  69. package/departments/dev/skills/diagram/vendor/scripts/render-examples.mjs +26 -0
  70. package/departments/dev/skills/spec/SKILL.md +19 -0
  71. package/departments/ops/agents/ops-lead.yaml +1 -0
  72. package/departments/pm/agents/pm-director.yaml +1 -0
  73. package/departments/strategy/agents/strategy-director.yaml +1 -0
  74. package/harness/codex/AGENTS.md +1 -1
  75. package/harness/copilot/copilot-instructions.md +1 -1
  76. package/harness/cursor/rules/arkaos.mdc +2 -2
  77. package/harness/gemini/GEMINI.md +1 -1
  78. package/harness/opencode/AGENTS.md +1 -1
  79. package/harness/zed/.rules +1 -1
  80. package/installer/autoupdate.js +249 -0
  81. package/installer/cli.js +7 -0
  82. package/installer/core-snapshot.js +5 -1
  83. package/installer/doctor.js +11 -0
  84. package/installer/index.js +11 -0
  85. package/installer/update.js +11 -0
  86. package/knowledge/agents-registry-v2.json +9 -1
  87. package/knowledge/skills-manifest.json +14 -1
  88. package/package.json +3 -2
  89. package/pyproject.toml +1 -1
  90. package/scripts/auto-update.sh +188 -0
  91. package/scripts/skill_validator.py +10 -2
  92. package/scripts/tools/docs_stats.py +8 -2
@@ -0,0 +1,992 @@
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
+ // In degraded mode (no ajv) a type-wrong top-level field reaches the renderer.
6
+ // Coerce non-arrays to [] so the module-level Maps build without throwing and
7
+ // the friendly validator checks (which run later) report the real problem.
8
+ export function asArray(value) {
9
+ return Array.isArray(value) ? value : [];
10
+ }
11
+
12
+ // A computed coordinate must be a finite number; NaN/undefined would silently
13
+ // write `<rect x="NaN">` into the output. Used by the validators as a backstop.
14
+ export function isFinitePoint(...coords) {
15
+ return coords.every((c) => Number.isFinite(c));
16
+ }
17
+
18
+ export function rectsOverlap(a, b, gap = 0) {
19
+ return !(
20
+ a.x + a.width + gap <= b.x ||
21
+ b.x + b.width + gap <= a.x ||
22
+ a.y + a.height + gap <= b.y ||
23
+ b.y + b.height + gap <= a.y
24
+ );
25
+ }
26
+
27
+ export function segmentIntersectsRect(segment, rect, gap = 0) {
28
+ const box = {
29
+ x1: rect.x - gap,
30
+ y1: rect.y - gap,
31
+ x2: rect.x + rect.width + gap,
32
+ y2: rect.y + rect.height + gap
33
+ };
34
+ const [a, b] = [segment.start, segment.end];
35
+ if (pointInBox(a, box) || pointInBox(b, box)) return true;
36
+ return (
37
+ segmentsIntersect(a, b, [box.x1, box.y1], [box.x2, box.y1]) ||
38
+ segmentsIntersect(a, b, [box.x2, box.y1], [box.x2, box.y2]) ||
39
+ segmentsIntersect(a, b, [box.x2, box.y2], [box.x1, box.y2]) ||
40
+ segmentsIntersect(a, b, [box.x1, box.y2], [box.x1, box.y1])
41
+ );
42
+ }
43
+
44
+ export function segmentRectClearance(segment, rect) {
45
+ if (!segment || !rect) return null;
46
+ const { start, end } = segment;
47
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
48
+ if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
49
+ if (rect.width < 0 || rect.height < 0) return null;
50
+ if (segmentIntersectsRect(segment, rect)) return 0;
51
+
52
+ const corners = [
53
+ [rect.x, rect.y],
54
+ [rect.x + rect.width, rect.y],
55
+ [rect.x + rect.width, rect.y + rect.height],
56
+ [rect.x, rect.y + rect.height],
57
+ ];
58
+ return Math.min(
59
+ pointRectDistance(start, rect),
60
+ pointRectDistance(end, rect),
61
+ ...corners.map((corner) => pointSegmentDistance(corner, start, end)),
62
+ );
63
+ }
64
+
65
+ export function segmentRectIntersectionLength(segment, rect) {
66
+ if (!segment || !rect) return null;
67
+ const { start, end } = segment;
68
+ if (!Array.isArray(start) || !Array.isArray(end) || start.length !== 2 || end.length !== 2) return null;
69
+ if (!isFinitePoint(...start, ...end, rect.x, rect.y, rect.width, rect.height)) return null;
70
+ if (rect.width < 0 || rect.height < 0) return null;
71
+
72
+ const dx = end[0] - start[0];
73
+ const dy = end[1] - start[1];
74
+ const length = Math.hypot(dx, dy);
75
+ if (length <= 0.0000001) return 0;
76
+ const bounds = [
77
+ [-dx, start[0] - rect.x],
78
+ [dx, rect.x + rect.width - start[0]],
79
+ [-dy, start[1] - rect.y],
80
+ [dy, rect.y + rect.height - start[1]],
81
+ ];
82
+ let enter = 0;
83
+ let leave = 1;
84
+ for (const [direction, distance] of bounds) {
85
+ if (Math.abs(direction) <= 0.0000001) {
86
+ if (distance < -0.0000001) return 0;
87
+ continue;
88
+ }
89
+ const ratio = distance / direction;
90
+ if (direction < 0) enter = Math.max(enter, ratio);
91
+ else leave = Math.min(leave, ratio);
92
+ if (enter > leave + 0.0000001) return 0;
93
+ }
94
+ return length * Math.max(0, leave - enter);
95
+ }
96
+
97
+ export function collectLabelRouteClearance({ labels, routedRelations, threshold }) {
98
+ if (!Number.isFinite(threshold) || threshold < 0) return [];
99
+ const routeCandidates = asArray(routedRelations).map((entry, fallbackIndex) => {
100
+ const relation = entry?.relation || entry;
101
+ const points = normalizeRoutePoints(entry?.points || relation?.routePoints);
102
+ if (!relation || points.length < 2) return null;
103
+ return {
104
+ relation,
105
+ relationIndex: Number.isInteger(entry?.relationIndex) ? entry.relationIndex : fallbackIndex,
106
+ points,
107
+ };
108
+ }).filter(Boolean);
109
+ const seenRoutes = new Set();
110
+ const routes = routeCandidates.filter((route) => {
111
+ const identity = relationshipIdentity(route.relation, route.relationIndex);
112
+ if (seenRoutes.has(identity)) return false;
113
+ seenRoutes.add(identity);
114
+ return true;
115
+ });
116
+ const hits = [];
117
+ const seenLabels = new Set();
118
+
119
+ for (const [fallbackIndex, label] of asArray(labels).entries()) {
120
+ const rect = label?.rect || label;
121
+ if (!rect || !isFinitePoint(rect.x, rect.y, rect.width, rect.height) || rect.width < 0 || rect.height < 0) continue;
122
+ const relationIndex = Number.isInteger(label?.relationIndex) ? label.relationIndex : fallbackIndex;
123
+ const labelIdentity = relationshipIdentity(label?.relation, relationIndex);
124
+ if (seenLabels.has(labelIdentity)) continue;
125
+ seenLabels.add(labelIdentity);
126
+ for (const route of routes) {
127
+ if (relationIndex === route.relationIndex || sameRelationship(label?.relation, route.relation)) continue;
128
+ let nearest = null;
129
+ for (let segmentIndex = 0; segmentIndex < route.points.length - 1; segmentIndex += 1) {
130
+ const start = route.points[segmentIndex];
131
+ const end = route.points[segmentIndex + 1];
132
+ const clearance = segmentRectClearance({ start, end }, rect);
133
+ if (clearance == null) continue;
134
+ if (!nearest || clearance < nearest.clearance) {
135
+ nearest = {
136
+ clearance,
137
+ intersectionLength: segmentRectIntersectionLength({ start, end }, rect),
138
+ segmentIndex,
139
+ start,
140
+ end,
141
+ };
142
+ }
143
+ }
144
+ if (!nearest || nearest.clearance + 0.0001 >= threshold) continue;
145
+ hits.push({
146
+ label,
147
+ labelRelation: label?.relation,
148
+ labelRelationIndex: relationIndex,
149
+ otherRelation: route.relation,
150
+ otherRelationIndex: route.relationIndex,
151
+ rect,
152
+ ...nearest,
153
+ threshold,
154
+ });
155
+ }
156
+ }
157
+ return hits;
158
+ }
159
+
160
+ function relationshipIdentity(relation, relationIndex) {
161
+ if (relation?.key !== undefined) return `key:${relation.key}`;
162
+ if (relation?.id) return `id:${relation.from || ''}\u0000${relation.to || ''}\u0000${relation.id}`;
163
+ return `index:${relationIndex}`;
164
+ }
165
+
166
+ function sameRelationship(left, right) {
167
+ if (!left || !right) return false;
168
+ if (left === right) return true;
169
+ if (left.key !== undefined && right.key !== undefined) return left.key === right.key;
170
+ return Boolean(left.id && right.id && left.id === right.id && left.from === right.from && left.to === right.to);
171
+ }
172
+
173
+ // One mechanical quality gate for every renderer-owned relationship path.
174
+ // A renderer supplies its semantic obstacle set; source/target boxes are
175
+ // always exempt because paths are expected to terminate on their boundaries.
176
+ // Containers, lifelines, and other intentionally pass-through geometry should
177
+ // simply not be supplied as obstacles.
178
+ export function cleanFlowProblems({
179
+ relations,
180
+ obstacles,
181
+ pathFor,
182
+ diagramType,
183
+ relationCollection,
184
+ obstacleKind,
185
+ profile,
186
+ clearance = 2,
187
+ routeHint = 'adjust fromSide/toSide, set route/via or channel coordinates, or move the obstacle'
188
+ }) {
189
+ // A relationship hidden behind an unrelated opaque node changes the
190
+ // diagram's meaning, so this is a correctness invariant rather than an
191
+ // opt-in composition preference. Keep it active even when the author omits
192
+ // quality_profile; standard/showcase still control stricter visual budgets.
193
+ const problems = [];
194
+ const obstacleList = [...obstacles];
195
+ const obstacleIds = new Set(obstacleList.map((obstacle) => obstacle?.id));
196
+ for (const [relationIndex, relation] of asArray(relations).entries()) {
197
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') continue;
198
+ if (!obstacleIds.has(relation.from) || !obstacleIds.has(relation.to)) continue;
199
+ const points = pathFor(relation)?.points;
200
+ if (!Array.isArray(points) || points.length < 2) continue;
201
+ if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) continue;
202
+
203
+ const endpointIds = new Set([relation.from, relation.to]);
204
+ for (const obstacle of obstacleList) {
205
+ if (!obstacle || endpointIds.has(obstacle.id)) continue;
206
+ if (!isFinitePoint(obstacle.x, obstacle.y, obstacle.width, obstacle.height)) continue;
207
+ let hitSegment = -1;
208
+ for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
209
+ if (segmentIntersectsRect({ start: points[segmentIndex], end: points[segmentIndex + 1] }, obstacle, clearance)) {
210
+ hitSegment = segmentIndex;
211
+ break;
212
+ }
213
+ }
214
+ if (hitSegment === -1) continue;
215
+ const from = points[hitSegment].map(Math.round).join(', ');
216
+ const to = points[hitSegment + 1].map(Math.round).join(', ');
217
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
218
+ problems.push(
219
+ `[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) — ${routeHint}.`
220
+ );
221
+ }
222
+ }
223
+ return problems;
224
+ }
225
+
226
+ // Reject only a proper interior X between relationships that share no semantic
227
+ // endpoint. Endpoint touches, branch/merge ports, and collinear shared
228
+ // corridors are intentionally outside this contract because geometry alone
229
+ // cannot tell whether those are authored junctions.
230
+ export function cleanCrossingProblems({
231
+ relations,
232
+ endpointIds,
233
+ pathFor,
234
+ diagramType,
235
+ relationCollection,
236
+ profile = 'standard',
237
+ routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors'
238
+ }) {
239
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
240
+ const activeProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
241
+ if (activeProfile !== 'showcase') return [];
242
+ const routed = asArray(relations).map((relation, index) => {
243
+ if (!relation || !endpointIds.has(relation.from) || !endpointIds.has(relation.to)) return null;
244
+ const points = pathFor(relation)?.points;
245
+ if (!Array.isArray(points) || points.length < 2) return null;
246
+ if (!points.every((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))) return null;
247
+ return { relation, index, points };
248
+ }).filter(Boolean);
249
+ const problems = [];
250
+
251
+ for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
252
+ const left = routed[leftIndex];
253
+ for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
254
+ const right = routed[rightIndex];
255
+ if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
256
+
257
+ let hit = null;
258
+ for (let leftSegment = 0; leftSegment < left.points.length - 1 && !hit; leftSegment += 1) {
259
+ for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
260
+ const point = properSegmentIntersection(
261
+ left.points[leftSegment],
262
+ left.points[leftSegment + 1],
263
+ right.points[rightSegment],
264
+ right.points[rightSegment + 1]
265
+ );
266
+ if (point) {
267
+ hit = { point, leftSegment, rightSegment };
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ if (!hit) continue;
273
+
274
+ const describe = ({ relation, index }) => {
275
+ const id = relation.id ? ` id "${relation.id}"` : '';
276
+ return `${relationCollection}[${index}]${id} "${relation.from}" -> "${relation.to}"`;
277
+ };
278
+ const point = hit.point.map((value) => Math.round(value * 10) / 10).join(', ');
279
+ problems.push(
280
+ `[composition/proper-crossing] showcase ${diagramType} ${describe(left)} crosses ${describe(right)} at [${point}] (segments ${hit.leftSegment} and ${hit.rightSegment}) — ${routeHint}.`
281
+ );
282
+ }
283
+ }
284
+ return problems;
285
+ }
286
+
287
+ // Two unrelated relationships that occupy the same visible corridor can read
288
+ // as one authored branch or merge even when neither relationship crosses a
289
+ // node or forms a proper X. Keep shared semantic endpoints exempt: their
290
+ // initial/final fan-out is real topology. Tiny overlaps below the route rhythm
291
+ // floor are ignored to avoid turning sub-pixel rounding into a quality debt.
292
+ export function collectAmbiguousCorridors({
293
+ routedRelations,
294
+ minOverlapPx = 8,
295
+ }) {
296
+ const routed = asArray(routedRelations).map((entry, fallbackIndex) => {
297
+ const relation = entry?.relation;
298
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
299
+ const points = normalizeRoutePoints(entry?.points);
300
+ if (points.length < 2) return null;
301
+ return {
302
+ relation,
303
+ relationIndex: Number.isInteger(entry.relationIndex) ? entry.relationIndex : fallbackIndex,
304
+ points,
305
+ };
306
+ }).filter(Boolean);
307
+ const hits = [];
308
+
309
+ for (let leftIndex = 0; leftIndex < routed.length; leftIndex += 1) {
310
+ const left = routed[leftIndex];
311
+ for (let rightIndex = leftIndex + 1; rightIndex < routed.length; rightIndex += 1) {
312
+ const right = routed[rightIndex];
313
+ if ([left.relation.from, left.relation.to].some((id) => id === right.relation.from || id === right.relation.to)) continue;
314
+
315
+ let longest = null;
316
+ for (let leftSegment = 0; leftSegment < left.points.length - 1; leftSegment += 1) {
317
+ for (let rightSegment = 0; rightSegment < right.points.length - 1; rightSegment += 1) {
318
+ const overlap = collinearAxisOverlap(
319
+ left.points[leftSegment],
320
+ left.points[leftSegment + 1],
321
+ right.points[rightSegment],
322
+ right.points[rightSegment + 1],
323
+ );
324
+ if (!overlap || overlap.length + 0.0001 < minOverlapPx) continue;
325
+ if (!longest || overlap.length > longest.overlapLength + 0.0001) {
326
+ longest = {
327
+ left,
328
+ right,
329
+ leftSegment,
330
+ rightSegment,
331
+ overlapLength: overlap.length,
332
+ overlapStart: overlap.start,
333
+ overlapEnd: overlap.end,
334
+ };
335
+ }
336
+ }
337
+ }
338
+ if (longest) hits.push(longest);
339
+ }
340
+ }
341
+ return hits;
342
+ }
343
+
344
+ export function cleanAmbiguousCorridorProblems({
345
+ relations,
346
+ endpointIds,
347
+ pathFor,
348
+ diagramType,
349
+ relationCollection,
350
+ profile = 'standard',
351
+ routeHint = 'adjust route/via or channel coordinates so the relationships use separate corridors',
352
+ minOverlapPx = 8,
353
+ }) {
354
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
355
+ if (requestedProfile !== 'showcase') return [];
356
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
357
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
358
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
359
+ return { relation, relationIndex, points: pathFor(relation)?.points };
360
+ }).filter(Boolean);
361
+
362
+ return collectAmbiguousCorridors({ routedRelations, minOverlapPx }).map((hit) => {
363
+ const describe = ({ relation, relationIndex }) => {
364
+ const id = relation.id ? ` id "${relation.id}"` : '';
365
+ return `${relationCollection}[${relationIndex}]${id} "${relation.from}" -> "${relation.to}"`;
366
+ };
367
+ const length = Math.round(hit.overlapLength * 10) / 10;
368
+ const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
369
+ const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
370
+ return `[composition/ambiguous-corridor] showcase ${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) — ${routeHint}.`;
371
+ });
372
+ }
373
+
374
+ // Relationship paths may cross a structural frame, but they must not borrow a
375
+ // frame side as a routing corridor. Rounded rectangle corners are trimmed from
376
+ // the modeled straight sides so a short corner touch is not mistaken for a
377
+ // border run. Any positive straight overlap beyond the numeric epsilon is a
378
+ // hard failure in every quality profile; 16px belongs only to the separate,
379
+ // neutral short-segment metric and is not a corridor exemption.
380
+ export function collectBorderRuns({ routedRelations, frames }) {
381
+ const hits = [];
382
+ for (const routed of asArray(routedRelations)) {
383
+ const routeSegments = Array.isArray(routed?.segments)
384
+ ? routed.segments
385
+ : asArray(routed?.points).slice(0, -1).map((start, index) => ({ start, end: routed.points[index + 1] }));
386
+ if (!routeSegments.length) continue;
387
+ if (!routeSegments.every((segment) => (
388
+ Array.isArray(segment?.start) && segment.start.length === 2 && isFinitePoint(...segment.start)
389
+ && Array.isArray(segment?.end) && segment.end.length === 2 && isFinitePoint(...segment.end)
390
+ ))) continue;
391
+ for (const [frameIndex, frame] of asArray(frames).entries()) {
392
+ for (const border of frameBorderSegments(frame)) {
393
+ const overlaps = [];
394
+ for (let segmentIndex = 0; segmentIndex < routeSegments.length; segmentIndex += 1) {
395
+ const segment = routeSegments[segmentIndex];
396
+ const overlap = collinearAxisOverlap(
397
+ segment.start,
398
+ segment.end,
399
+ border.start,
400
+ border.end,
401
+ );
402
+ if (!overlap || overlap.length <= 0.0001) continue;
403
+ overlaps.push({ ...overlap, segmentIndex });
404
+ }
405
+ if (!overlaps.length) continue;
406
+ const merged = mergeBorderOverlaps(overlaps, border);
407
+ const longest = [...merged].sort((left, right) => right.length - left.length || left.low - right.low)[0];
408
+ hits.push({
409
+ ...routed,
410
+ frame,
411
+ frameIndex,
412
+ side: border.side,
413
+ segmentIndex: Math.min(...overlaps.map((overlap) => overlap.segmentIndex)),
414
+ overlapLength: merged.reduce((total, overlap) => total + overlap.length, 0),
415
+ overlapStart: longest.start,
416
+ overlapEnd: longest.end,
417
+ });
418
+ }
419
+ }
420
+ }
421
+ return hits;
422
+ }
423
+
424
+ export function cleanBorderRunProblems({
425
+ relations,
426
+ endpointIds,
427
+ frames,
428
+ pathFor,
429
+ diagramType,
430
+ relationCollection,
431
+ profile,
432
+ routeHint = 'adjust route/via or channel coordinates so the relationship crosses the frame perpendicularly through a clear opening'
433
+ }) {
434
+ if (!process.env.ARCHIFY_QUALITY_PROFILE && !profile) return [];
435
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
436
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
437
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
438
+ return { relation, relationIndex, points: pathFor(relation)?.points };
439
+ }).filter(Boolean);
440
+ return collectBorderRuns({ routedRelations, frames }).map((hit) => {
441
+ const relation = hit.relation || {};
442
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
443
+ const frameKind = hit.frame?.kind || hit.frame?.shape || 'frame';
444
+ const frameIdentity = hit.frame?.label || hit.frame?.id || hit.frameIndex;
445
+ const length = Math.round(hit.overlapLength * 10) / 10;
446
+ const from = hit.overlapStart.map((value) => Math.round(value * 10) / 10).join(', ');
447
+ const to = hit.overlapEnd.map((value) => Math.round(value * 10) / 10).join(', ');
448
+ return `[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}] — ${routeHint}.`;
449
+ });
450
+ }
451
+
452
+ export function routeBudgetMetrics({
453
+ routedRelations,
454
+ bendsPerRelationship = 2,
455
+ stretch = 1.35,
456
+ segmentPx = 16,
457
+ microSegmentPx = 8,
458
+ }) {
459
+ let maxBends = 0;
460
+ let routesOverSuggestedBends = 0;
461
+ let maxStretch = null;
462
+ let routesOverSuggestedStretch = 0;
463
+ let minSegmentPx = null;
464
+ let minInteriorSegmentPx = null;
465
+ let shortSegmentCount = 0;
466
+ let shortEndpointSegmentCount = 0;
467
+ let shortInteriorSegmentCount = 0;
468
+ let microSegmentCount = 0;
469
+
470
+ for (const routed of asArray(routedRelations)) {
471
+ const points = normalizeRoutePoints(routed?.points);
472
+ if (points.length < 2) continue;
473
+ const bends = Math.max(0, points.length - 2);
474
+ maxBends = Math.max(maxBends, bends);
475
+ if (bends > bendsPerRelationship) routesOverSuggestedBends += 1;
476
+
477
+ let routeLength = 0;
478
+ for (let index = 0; index < points.length - 1; index += 1) {
479
+ const length = Math.abs(points[index + 1][0] - points[index][0]) + Math.abs(points[index + 1][1] - points[index][1]);
480
+ if (length <= 0.0001) continue;
481
+ const position = segmentPosition(index, points.length - 1);
482
+ routeLength += length;
483
+ minSegmentPx = minSegmentPx == null ? length : Math.min(minSegmentPx, length);
484
+ if (position === 'interior') {
485
+ minInteriorSegmentPx = minInteriorSegmentPx == null ? length : Math.min(minInteriorSegmentPx, length);
486
+ }
487
+ if (length < segmentPx) {
488
+ shortSegmentCount += 1;
489
+ if (position === 'interior') shortInteriorSegmentCount += 1;
490
+ else shortEndpointSegmentCount += 1;
491
+ }
492
+ if (length < microSegmentPx) microSegmentCount += 1;
493
+ }
494
+ const direct = Math.abs(points.at(-1)[0] - points[0][0]) + Math.abs(points.at(-1)[1] - points[0][1]);
495
+ if (direct > 0.0001) {
496
+ const routeStretch = routeLength / direct;
497
+ maxStretch = maxStretch == null ? routeStretch : Math.max(maxStretch, routeStretch);
498
+ if (routeStretch > stretch + 0.0001) routesOverSuggestedStretch += 1;
499
+ }
500
+ }
501
+
502
+ return {
503
+ maxBends,
504
+ routesOverSuggestedBends,
505
+ maxStretch,
506
+ routesOverSuggestedStretch,
507
+ minSegmentPx,
508
+ minInteriorSegmentPx,
509
+ shortSegmentCount,
510
+ shortEndpointSegmentCount,
511
+ shortInteriorSegmentCount,
512
+ microSegmentCount,
513
+ };
514
+ }
515
+
516
+ export function collectRouteRhythmIssues({
517
+ routedRelations,
518
+ interiorSegmentPx = 16,
519
+ microSegmentPx = 8,
520
+ }) {
521
+ const issues = [];
522
+ for (const [fallbackIndex, routed] of asArray(routedRelations).entries()) {
523
+ const points = normalizeRoutePoints(routed?.points);
524
+ if (points.length < 2) continue;
525
+ for (let segmentIndex = 0; segmentIndex < points.length - 1; segmentIndex += 1) {
526
+ const start = points[segmentIndex];
527
+ const end = points[segmentIndex + 1];
528
+ const length = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
529
+ if (length <= 0.0001) continue;
530
+ const position = segmentPosition(segmentIndex, points.length - 1);
531
+ const code = length < microSegmentPx - 0.0001
532
+ ? 'composition/micro-segment'
533
+ : position === 'interior' && length < interiorSegmentPx - 0.0001
534
+ ? 'composition/short-interior-segment'
535
+ : null;
536
+ if (!code) continue;
537
+ issues.push({
538
+ code,
539
+ relation: routed.relation,
540
+ relationIndex: Number.isInteger(routed.relationIndex) ? routed.relationIndex : fallbackIndex,
541
+ segmentIndex,
542
+ position,
543
+ length,
544
+ start,
545
+ end,
546
+ });
547
+ }
548
+ }
549
+ return issues;
550
+ }
551
+
552
+ export function cleanRouteRhythmProblems({
553
+ relations,
554
+ endpointIds,
555
+ pathFor,
556
+ diagramType,
557
+ relationCollection,
558
+ profile,
559
+ routeHint = 'move the channel/via point to remove the cramped turn or give the route more corridor space',
560
+ interiorSegmentPx = 16,
561
+ microSegmentPx = 8,
562
+ }) {
563
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
564
+ if (requestedProfile !== 'showcase') return [];
565
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
566
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
567
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
568
+ return { relation, relationIndex, points: pathFor(relation)?.points };
569
+ }).filter(Boolean);
570
+ return collectRouteRhythmIssues({ routedRelations, interiorSegmentPx, microSegmentPx }).map((hit) => {
571
+ const relation = hit.relation || {};
572
+ const relationId = relation.id ? ` id "${relation.id}"` : '';
573
+ const length = Math.round(hit.length * 10) / 10;
574
+ const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
575
+ const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
576
+ const rule = hit.code === 'composition/micro-segment'
577
+ ? `is below the ${microSegmentPx}px micro-segment floor`
578
+ : `is below the ${interiorSegmentPx}px interior-segment floor`;
579
+ return `[${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} — ${routeHint}.`;
580
+ });
581
+ }
582
+
583
+ export function cleanLabelRouteClearanceProblems({
584
+ relations,
585
+ labels,
586
+ endpointIds,
587
+ pathFor,
588
+ diagramType,
589
+ relationCollection,
590
+ profile,
591
+ threshold = 4,
592
+ routeHint = 'adjust labelAt, labelDx, labelDy, or labelSegment; otherwise adjust the other relationship route/via/channel',
593
+ }) {
594
+ const requestedProfile = process.env.ARCHIFY_QUALITY_PROFILE || profile;
595
+ if (requestedProfile !== 'showcase') return [];
596
+ const routedRelations = asArray(relations).map((relation, relationIndex) => {
597
+ if (!relation || typeof relation.from !== 'string' || typeof relation.to !== 'string') return null;
598
+ if (endpointIds && (!endpointIds.has(relation.from) || !endpointIds.has(relation.to))) return null;
599
+ return { relation, relationIndex, points: pathFor(relation)?.points };
600
+ }).filter(Boolean);
601
+ return collectLabelRouteClearance({ labels, routedRelations, threshold }).map((hit) => {
602
+ const describe = (relation, relationIndex) => {
603
+ const relationId = relation?.id ? ` id "${relation.id}"` : '';
604
+ const relationLabel = relation?.label ? ` label "${relation.label}"` : '';
605
+ return `${relationCollection}[${relationIndex}]${relationId} "${relation?.from}" -> "${relation?.to}"${relationLabel}`;
606
+ };
607
+ const clearance = Math.round(hit.clearance * 10) / 10;
608
+ const from = hit.start.map((value) => Math.round(value * 10) / 10).join(', ');
609
+ const to = hit.end.map((value) => Math.round(value * 10) / 10).join(', ');
610
+ return `[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) — ${routeHint}.`;
611
+ });
612
+ }
613
+
614
+ function segmentPosition(index, segmentCount) {
615
+ if (index === 0) return 'source-stub';
616
+ if (index === segmentCount - 1) return 'target-stub';
617
+ return 'interior';
618
+ }
619
+
620
+ function normalizeRoutePoints(points) {
621
+ const finite = asArray(points).filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point));
622
+ const deduped = [];
623
+ for (const point of finite) {
624
+ const previous = deduped.at(-1);
625
+ if (!previous || Math.abs(point[0] - previous[0]) > 0.0001 || Math.abs(point[1] - previous[1]) > 0.0001) deduped.push(point);
626
+ }
627
+ const normalized = [];
628
+ for (const point of deduped) {
629
+ while (normalized.length >= 2 && collinearForward(normalized.at(-2), normalized.at(-1), point)) normalized.pop();
630
+ normalized.push(point);
631
+ }
632
+ return normalized;
633
+ }
634
+
635
+ function pointRectDistance(point, rect) {
636
+ const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
637
+ const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
638
+ return Math.hypot(dx, dy);
639
+ }
640
+
641
+ function pointSegmentDistance(point, start, end) {
642
+ const dx = end[0] - start[0];
643
+ const dy = end[1] - start[1];
644
+ const lengthSquared = dx * dx + dy * dy;
645
+ if (lengthSquared <= 0.0000001) return Math.hypot(point[0] - start[0], point[1] - start[1]);
646
+ const projection = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared));
647
+ return Math.hypot(point[0] - (start[0] + projection * dx), point[1] - (start[1] + projection * dy));
648
+ }
649
+
650
+ function collinearForward(a, b, c) {
651
+ if (Math.abs(crossProduct(a, b, c)) > 0.0001) return false;
652
+ return (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) >= -0.0001;
653
+ }
654
+
655
+ function frameBorderSegments(frame) {
656
+ if (!frame || typeof frame !== 'object') return [];
657
+ if (frame.shape === 'line') {
658
+ const start = frame.start || [frame.x1, frame.y1];
659
+ const end = frame.end || [frame.x2, frame.y2];
660
+ return isFinitePoint(...start, ...end) ? [{ side: 'line', start, end }] : [];
661
+ }
662
+ if (!isFinitePoint(frame.x, frame.y, frame.width, frame.height) || frame.width <= 0 || frame.height <= 0) return [];
663
+ const radius = Math.max(0, Math.min(Number(frame.radius) || 0, frame.width / 2, frame.height / 2));
664
+ const left = frame.x;
665
+ const right = frame.x + frame.width;
666
+ const top = frame.y;
667
+ const bottom = frame.y + frame.height;
668
+ return [
669
+ { side: 'top', start: [left + radius, top], end: [right - radius, top] },
670
+ { side: 'right', start: [right, top + radius], end: [right, bottom - radius] },
671
+ { side: 'bottom', start: [right - radius, bottom], end: [left + radius, bottom] },
672
+ { side: 'left', start: [left, bottom - radius], end: [left, top + radius] },
673
+ ].filter(({ start, end }) => Math.hypot(end[0] - start[0], end[1] - start[1]) > 0.0001);
674
+ }
675
+
676
+ function mergeBorderOverlaps(overlaps, border) {
677
+ const horizontal = Math.abs(border.start[1] - border.end[1]) <= 0.0001;
678
+ const axis = horizontal ? 0 : 1;
679
+ const fixed = horizontal ? border.start[1] : border.start[0];
680
+ const sorted = overlaps.map((overlap) => ({
681
+ low: Math.min(overlap.start[axis], overlap.end[axis]),
682
+ high: Math.max(overlap.start[axis], overlap.end[axis]),
683
+ })).sort((left, right) => left.low - right.low || left.high - right.high);
684
+ const merged = [];
685
+ for (const interval of sorted) {
686
+ const previous = merged.at(-1);
687
+ if (previous && interval.low <= previous.high + 0.0001) previous.high = Math.max(previous.high, interval.high);
688
+ else merged.push({ ...interval });
689
+ }
690
+ return merged.map((interval) => ({
691
+ ...interval,
692
+ length: interval.high - interval.low,
693
+ start: horizontal ? [interval.low, fixed] : [fixed, interval.low],
694
+ end: horizontal ? [interval.high, fixed] : [fixed, interval.high],
695
+ }));
696
+ }
697
+
698
+ function collinearAxisOverlap(a, b, c, d) {
699
+ const epsilon = 0.0001;
700
+ const horizontal = Math.abs(a[1] - b[1]) <= epsilon
701
+ && Math.abs(c[1] - d[1]) <= epsilon
702
+ && Math.abs(a[1] - c[1]) <= epsilon;
703
+ const vertical = Math.abs(a[0] - b[0]) <= epsilon
704
+ && Math.abs(c[0] - d[0]) <= epsilon
705
+ && Math.abs(a[0] - c[0]) <= epsilon;
706
+ if (!horizontal && !vertical) return null;
707
+ const axis = horizontal ? 0 : 1;
708
+ const low = Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
709
+ const high = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis]));
710
+ if (high - low <= epsilon) return null;
711
+ const fixed = horizontal ? a[1] : a[0];
712
+ return {
713
+ length: high - low,
714
+ start: horizontal ? [low, fixed] : [fixed, low],
715
+ end: horizontal ? [high, fixed] : [fixed, high],
716
+ };
717
+ }
718
+
719
+ function properSegmentIntersection(a, b, c, d) {
720
+ const abC = crossProduct(a, b, c);
721
+ const abD = crossProduct(a, b, d);
722
+ const cdA = crossProduct(c, d, a);
723
+ const cdB = crossProduct(c, d, b);
724
+ const epsilon = 0.0001;
725
+ const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
726
+ if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
727
+
728
+ const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
729
+ if (Math.abs(denominator) < epsilon) return null;
730
+ const ab = a[0] * b[1] - a[1] * b[0];
731
+ const cd = c[0] * d[1] - c[1] * d[0];
732
+ return [
733
+ (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
734
+ (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator
735
+ ];
736
+ }
737
+
738
+ function crossProduct(a, b, c) {
739
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
740
+ }
741
+
742
+ function pointInBox(point, box) {
743
+ return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
744
+ }
745
+
746
+ function segmentsIntersect(a, b, c, d) {
747
+ const o1 = orientation(a, b, c);
748
+ const o2 = orientation(a, b, d);
749
+ const o3 = orientation(c, d, a);
750
+ const o4 = orientation(c, d, b);
751
+
752
+ if (o1 === 0 && onSegment(a, c, b)) return true;
753
+ if (o2 === 0 && onSegment(a, d, b)) return true;
754
+ if (o3 === 0 && onSegment(c, a, d)) return true;
755
+ if (o4 === 0 && onSegment(c, b, d)) return true;
756
+
757
+ return o1 !== o2 && o3 !== o4;
758
+ }
759
+
760
+ function orientation(a, b, c) {
761
+ const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
762
+ if (Math.abs(value) < 0.0001) return 0;
763
+ return value > 0 ? 1 : 2;
764
+ }
765
+
766
+ function onSegment(a, b, c) {
767
+ return (
768
+ b[0] <= Math.max(a[0], c[0]) &&
769
+ b[0] >= Math.min(a[0], c[0]) &&
770
+ b[1] <= Math.max(a[1], c[1]) &&
771
+ b[1] >= Math.min(a[1], c[1])
772
+ );
773
+ }
774
+
775
+ export function anchor(rect, side) {
776
+ switch (side) {
777
+ case 'left': return [rect.x, rect.cy];
778
+ case 'right': return [rect.x + rect.width, rect.cy];
779
+ case 'top': return [rect.cx, rect.y];
780
+ case 'bottom': return [rect.cx, rect.y + rect.height];
781
+ default:
782
+ return [rect.x + rect.width, rect.cy];
783
+ }
784
+ }
785
+
786
+ // Keep conservative auto-routed fan-out/fan-in relationships visually
787
+ // distinct without changing authored route controls. The returned map only
788
+ // contains endpoints that belong to a shared automatic midpoint anchor.
789
+ export function automaticPortSpread(relations, boxes, { gutter = 16, maxSpacing = 14 } = {}) {
790
+ const groups = new Map();
791
+ const spread = new Map();
792
+
793
+ const add = (relation, endpoint, rect, side, counterpart) => {
794
+ const key = `${rect.id}\u0000${side}`;
795
+ const items = groups.get(key) || [];
796
+ items.push({ relation, endpoint, rect, side, counterpart });
797
+ groups.set(key, items);
798
+ };
799
+
800
+ for (const relation of asArray(relations)) {
801
+ if (!relation || (relation.route && relation.route !== 'auto')) continue;
802
+ if (relation.via || relation.channelX !== undefined || relation.channelY !== undefined || relation.labelAt) continue;
803
+ const from = boxes.get(relation.from);
804
+ const to = boxes.get(relation.to);
805
+ if (!from || !to) continue;
806
+ const fromSide = chosenSide(relation.fromSide, defaultFromSide(from, to));
807
+ const toSide = chosenSide(relation.toSide, defaultToSide(from, to));
808
+ add(relation, 'from', from, fromSide, to);
809
+ add(relation, 'to', to, toSide, from);
810
+ }
811
+
812
+ for (const items of groups.values()) {
813
+ if (items.length < 2) continue;
814
+ const verticalSide = items[0].side === 'left' || items[0].side === 'right';
815
+ items.sort((a, b) => {
816
+ const aCoordinate = verticalSide ? a.counterpart.cy : a.counterpart.cx;
817
+ const bCoordinate = verticalSide ? b.counterpart.cy : b.counterpart.cx;
818
+ if (aCoordinate !== bCoordinate) return aCoordinate - bCoordinate;
819
+ const aKey = `${a.relation.id || ''}\u0000${a.relation.from}\u0000${a.relation.to}\u0000${a.relation.label || ''}`;
820
+ const bKey = `${b.relation.id || ''}\u0000${b.relation.from}\u0000${b.relation.to}\u0000${b.relation.label || ''}`;
821
+ return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
822
+ });
823
+
824
+ const extent = verticalSide ? items[0].rect.height : items[0].rect.width;
825
+ const usable = Math.max(0, extent - gutter * 2);
826
+ const spacing = Math.min(maxSpacing, usable / (items.length - 1));
827
+ if (!(spacing > 0)) continue;
828
+
829
+ for (const [index, item] of items.entries()) {
830
+ const offset = (index - (items.length - 1) / 2) * spacing;
831
+ const point = anchor(item.rect, item.side);
832
+ if (verticalSide) point[1] += offset;
833
+ else point[0] += offset;
834
+ const endpoints = spread.get(item.relation) || {};
835
+ endpoints[item.endpoint] = point;
836
+ spread.set(item.relation, endpoints);
837
+ }
838
+ }
839
+
840
+ return spread;
841
+ }
842
+
843
+ export function defaultFromSide(from, to) {
844
+ if (to.cx < from.cx) return 'left';
845
+ if (to.cx > from.cx) return 'right';
846
+ if (to.cy > from.cy) return 'bottom';
847
+ return 'top';
848
+ }
849
+
850
+ export function defaultToSide(from, to) {
851
+ if (to.cx < from.cx) return 'right';
852
+ if (to.cx > from.cx) return 'left';
853
+ if (to.cy > from.cy) return 'top';
854
+ return 'bottom';
855
+ }
856
+
857
+ export function chosenSide(side, fallback) {
858
+ return side && side !== 'auto' ? side : fallback;
859
+ }
860
+
861
+ export function polylinePath(points) {
862
+ return points.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
863
+ }
864
+
865
+ export function routePointsValue(points) {
866
+ return asArray(points)
867
+ .filter((point) => Array.isArray(point) && point.length === 2 && isFinitePoint(...point))
868
+ .map(([x, y]) => `${x},${y}`)
869
+ .join(';');
870
+ }
871
+
872
+ export function roundedPath(points, radius) {
873
+ if (points.length < 3 || radius <= 0) {
874
+ return polylinePath(points);
875
+ }
876
+
877
+ const commands = [`M ${points[0][0]} ${points[0][1]}`];
878
+ for (let i = 1; i < points.length - 1; i += 1) {
879
+ const [px, py] = points[i - 1];
880
+ const [cx, cy] = points[i];
881
+ const [nx, ny] = points[i + 1];
882
+ const prevLen = Math.hypot(cx - px, cy - py);
883
+ const nextLen = Math.hypot(nx - cx, ny - cy);
884
+ const r = Math.min(radius, prevLen / 2, nextLen / 2);
885
+ if (r < 1) {
886
+ commands.push(`L ${cx} ${cy}`);
887
+ continue;
888
+ }
889
+ const before = [cx - ((cx - px) / prevLen) * r, cy - ((cy - py) / prevLen) * r];
890
+ const after = [cx + ((nx - cx) / nextLen) * r, cy + ((ny - cy) / nextLen) * r];
891
+ commands.push(`L ${before[0]} ${before[1]}`);
892
+ commands.push(`Q ${cx} ${cy} ${after[0]} ${after[1]}`);
893
+ }
894
+ const [endX, endY] = points[points.length - 1];
895
+ commands.push(`L ${endX} ${endY}`);
896
+ return commands.join(' ');
897
+ }
898
+
899
+ // Shared by edges/flows/transitions: all carry the same optional
900
+ // labelAt/labelDx/labelDy/labelSegment knobs.
901
+ export function labelPoint(item, points) {
902
+ if (item.labelAt) return item.labelAt;
903
+ if (points.length === 2) {
904
+ return [
905
+ (points[0][0] + points[1][0]) / 2 + (item.labelDx || 0),
906
+ points[0][1] - 10 + (item.labelDy || 0)
907
+ ];
908
+ }
909
+ const segmentIndex = Math.min(points.length - 2, Math.max(0, item.labelSegment ?? 1));
910
+ const a = points[segmentIndex];
911
+ const b = points[segmentIndex + 1];
912
+ return [(a[0] + b[0]) / 2 + (item.labelDx || 0), (a[1] + b[1]) / 2 - 10 + (item.labelDy || 0)];
913
+ }
914
+
915
+ export const componentFill = {
916
+ frontend: 'c-frontend',
917
+ backend: 'c-backend',
918
+ database: 'c-database',
919
+ cloud: 'c-cloud',
920
+ security: 'c-security',
921
+ messagebus: 'c-messagebus',
922
+ external: 'c-external'
923
+ };
924
+
925
+ export const componentText = {
926
+ frontend: 't-frontend',
927
+ backend: 't-backend',
928
+ database: 't-database',
929
+ cloud: 't-cloud',
930
+ security: 't-security',
931
+ messagebus: 't-messagebus',
932
+ external: 't-external'
933
+ };
934
+
935
+ export const arrowClassMap = {
936
+ default: ['a-default', 'arrowhead'],
937
+ emphasis: ['a-emphasis', 'arrowhead-emphasis'],
938
+ security: ['a-security', 'arrowhead-security'],
939
+ dashed: ['a-dashed', 'arrowhead-dashed']
940
+ };
941
+
942
+ // Label accent per edge variant. Workflow colors dashed (async trace) labels
943
+ // like the trace store it points at; the other renderers use the bus color.
944
+ export function variantAccent(variant, { dashed = 't-messagebus' } = {}) {
945
+ return variant === 'security'
946
+ ? 't-security'
947
+ : variant === 'emphasis'
948
+ ? 't-backend'
949
+ : variant === 'dashed'
950
+ ? dashed
951
+ : 't-muted';
952
+ }
953
+
954
+ export function formatRect(r) {
955
+ return `[${Math.round(r.x)}, ${Math.round(r.y)}, ${Math.round(r.width)}, ${Math.round(r.height)}]`;
956
+ }
957
+
958
+ function formatDelta(n) {
959
+ const v = Math.round(n);
960
+ return v >= 0 ? `+${v}` : String(v);
961
+ }
962
+
963
+ /** Actionable hint when an edge label rect hits a node/component box (#7). */
964
+ export function suggestLabelObstacleFix(labelRect, lx, ly, obstacle, obstacleKind = 'component') {
965
+ const lxR = Math.round(lx);
966
+ const lyR = Math.round(ly);
967
+ const belowY = Math.round(obstacle.y + obstacle.height + 14);
968
+ const aboveY = Math.round(obstacle.y - 4);
969
+ return [
970
+ ` label rect: ${formatRect(labelRect)}`,
971
+ ` ${obstacleKind} "${obstacle.id}" rect: ${formatRect(obstacle)}`,
972
+ ` Suggested fix: labelAt [${lxR}, ${belowY}] or labelDy ${formatDelta(belowY - lyR)} (below); or labelAt [${lxR}, ${aboveY}] or labelDy ${formatDelta(aboveY - lyR)} (above)`,
973
+ ].join('\n');
974
+ }
975
+
976
+ /** Hint when two edge labels collide. */
977
+ export function suggestLabelPairFix(a, b) {
978
+ return [
979
+ ` "${a.label}" ${formatRect(a)}; "${b.label}" ${formatRect(b)}`,
980
+ ' Suggested fix: add labelDy +24 on one edge, adjust labelDx, or remove one label',
981
+ ].join('\n');
982
+ }
983
+
984
+ /** Hint when two components/nodes are too close. */
985
+ export function suggestComponentSeparation(a, b, minGap = 8) {
986
+ const rightX = Math.round(a.x + a.width + minGap);
987
+ const belowY = Math.round(a.y + a.height + minGap);
988
+ return [
989
+ ` "${a.id}" ${formatRect(a)}; "${b.id}" ${formatRect(b)}`,
990
+ ` Suggested fix: move "${b.id}" pos to [${rightX}, ${Math.round(b.y)}] (right of "${a.id}") or [${Math.round(b.x)}, ${belowY}] (below)`,
991
+ ].join('\n');
992
+ }