astro-archify 0.3.4

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 (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/astro-archify-integration.d.ts +100 -0
  4. package/astro-archify-integration.js +0 -0
  5. package/package.json +64 -0
  6. package/vendor/archify/LICENSE +22 -0
  7. package/vendor/archify/NOTICE.md +48 -0
  8. package/vendor/archify/assets/template.html +14787 -0
  9. package/vendor/archify/renderers/architecture/grid.mjs +62 -0
  10. package/vendor/archify/renderers/architecture/render-architecture.mjs +1089 -0
  11. package/vendor/archify/renderers/dataflow/render-dataflow.mjs +482 -0
  12. package/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +570 -0
  13. package/vendor/archify/renderers/sequence/render-sequence.mjs +468 -0
  14. package/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  15. package/vendor/archify/renderers/shared/cli.mjs +220 -0
  16. package/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  17. package/vendor/archify/renderers/shared/diagnostics.mjs +116 -0
  18. package/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  19. package/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  20. package/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  21. package/vendor/archify/renderers/shared/geometry.mjs +1334 -0
  22. package/vendor/archify/renderers/shared/i18n.mjs +594 -0
  23. package/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  24. package/vendor/archify/renderers/shared/legend.mjs +217 -0
  25. package/vendor/archify/renderers/shared/output-path.mjs +321 -0
  26. package/vendor/archify/renderers/shared/repository-evidence.mjs +235 -0
  27. package/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  28. package/vendor/archify/renderers/shared/utils.mjs +232 -0
  29. package/vendor/archify/renderers/shared/validator.mjs +86 -0
  30. package/vendor/archify/renderers/workflow/render-workflow.mjs +749 -0
@@ -0,0 +1,1089 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4
+ import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
+ import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
6
+ import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
7
+ import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
8
+ import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
9
+ import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
10
+ import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
11
+ import { translateMessage as i18nText } from '../shared/i18n.mjs';
12
+ import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
13
+ import {
14
+ asArray,
15
+ isFinitePoint,
16
+ rectsOverlap,
17
+ segmentIntersectsRect,
18
+ cleanEndpointSideProblems,
19
+ cleanFlowProblems,
20
+ cleanCrossingProblems,
21
+ cleanAmbiguousCorridorProblems,
22
+ cleanBorderRunProblems,
23
+ cleanRouteRhythmProblems,
24
+ cleanLabelRouteClearanceProblems,
25
+ suggestLabelObstacleFix,
26
+ suggestComponentSeparation,
27
+ anchor,
28
+ automaticPortSpread,
29
+ automaticPortRhythmBridge,
30
+ defaultFromSide,
31
+ defaultToSide,
32
+ chosenSide,
33
+ routeHonorsEndpointSides,
34
+ normalizeRoutePoints,
35
+ polylinePath,
36
+ routePointsValue,
37
+ roundedPath,
38
+ labelPoint,
39
+ componentFill,
40
+ componentText,
41
+ arrowClassMap,
42
+ variantAccent,
43
+ } from '../shared/geometry.mjs';
44
+
45
+ const componentTextFit = {
46
+ sublabelPreferred: 9,
47
+ sublabelMinimum: 6,
48
+ tagPreferred: 7,
49
+ tagMinimum: 6,
50
+ };
51
+
52
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
53
+ const layoutJsonMode = process.argv.includes('--layout-json');
54
+ const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
55
+ const { diagram: arch, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
56
+ rendererDir: __dirname,
57
+ diagramType: 'architecture',
58
+ defaultExample: 'web-app.architecture.json',
59
+ argv: cliArgs,
60
+ });
61
+
62
+ const grid = gridLayout(arch);
63
+
64
+ const layout = {
65
+ defaultW: 120,
66
+ defaultH: 60,
67
+ margin: 40,
68
+ // Boundary padding — the 30/50 rule that was a hand-arithmetic footgun
69
+ // (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
70
+ boundaryPad: 30,
71
+ boundaryExtraBottom: 20,
72
+ boundaryLabelBaseline: 18,
73
+ boundaryLabelClearance: 4,
74
+ boundaryLabelFontPreferred: 9,
75
+ boundaryLabelFontMinimum: 6,
76
+ boundaryLabelMaskHeight: 16,
77
+ boundaryLabelRailGap: 2,
78
+ boundaryLabelFrameInset: 4,
79
+ legendH: 28,
80
+ };
81
+
82
+ const LEGEND_CATALOG = [
83
+ 'frontend',
84
+ 'backend',
85
+ 'database',
86
+ 'cloud',
87
+ 'security',
88
+ 'messagebus',
89
+ 'external',
90
+ ].map((kind) => ({ kind, label: i18nText(arch.meta.locale, `legend.architecture.${kind}`) }));
91
+
92
+ // ---- Measure components from free coordinates --------------------------------
93
+ function measureComponent(c) {
94
+ const [x, y] = resolveComponentPos(c, grid);
95
+ const [w, h] = Array.isArray(c.size) ? c.size : [layout.defaultW, layout.defaultH];
96
+ return { ...c, x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 };
97
+ }
98
+
99
+ const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)]));
100
+ const enforcesBoundaryTitleComposition = Boolean(arch.meta?.quality_profile);
101
+ const componentSteps = new Map();
102
+ for (const [index, conn] of asArray(arch.connections).entries()) {
103
+ if (!componentSteps.has(conn.from)) componentSteps.set(conn.from, index);
104
+ if (!componentSteps.has(conn.to)) componentSteps.set(conn.to, index + 1);
105
+ }
106
+ for (const [index, c] of asArray(arch.components).entries()) {
107
+ if (!componentSteps.has(c.id)) componentSteps.set(c.id, index);
108
+ }
109
+
110
+ // ---- Boundaries computed from the `wraps` id list ---------------------------
111
+ function boundaryRect(boundary) {
112
+ const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean);
113
+ if (!members.length) return null;
114
+ const minX = Math.min(...members.map((m) => m.x));
115
+ const minY = Math.min(...members.map((m) => m.y));
116
+ const maxX = Math.max(...members.map((m) => m.x + m.width));
117
+ const maxY = Math.max(...members.map((m) => m.y + m.height));
118
+ const pad = boundary.pad ?? layout.boundaryPad;
119
+ const topPad = Math.max(
120
+ pad,
121
+ layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
122
+ );
123
+ return {
124
+ ...boundary,
125
+ x: minX - pad,
126
+ y: minY - topPad,
127
+ width: maxX - minX + pad * 2,
128
+ height: maxY - minY + topPad + layout.boundaryExtraBottom,
129
+ memberTop: minY,
130
+ };
131
+ }
132
+
133
+ function rectContains(outer, inner) {
134
+ const epsilon = 1e-9;
135
+ return outer.x <= inner.x + epsilon
136
+ && outer.y <= inner.y + epsilon
137
+ && outer.x + outer.width + epsilon >= inner.x + inner.width
138
+ && outer.y + outer.height + epsilon >= inner.y + inner.height;
139
+ }
140
+
141
+ function boundaryLabelWidth(label, fontSize) {
142
+ return Math.max(30, textUnits(label) * fontSize * 0.6 + 10);
143
+ }
144
+
145
+ const architectureLegendEntries = resolveLegend(
146
+ arch.meta?.legend,
147
+ LEGEND_CATALOG,
148
+ new Set([...components.values()].map((component) => component.type)),
149
+ );
150
+
151
+ function autoViewBoxFor(candidateBoundaries) {
152
+ const maxX = Math.max(
153
+ 0,
154
+ ...[...components.values()].map((component) => component.x + component.width),
155
+ ...candidateBoundaries.map((boundary) => boundary.x + boundary.width),
156
+ );
157
+ const maxY = Math.max(
158
+ 0,
159
+ ...[...components.values()].map((component) => component.y + component.height),
160
+ ...candidateBoundaries.map((boundary) => boundary.y + boundary.height),
161
+ );
162
+ let width = Math.ceil(maxX + layout.margin);
163
+ let footprint = legendFootprint(architectureLegendEntries, {
164
+ width: Math.max(1, width - layout.margin * 2),
165
+ });
166
+ if (footprint.minWidth > width - layout.margin * 2) {
167
+ width = Math.ceil(footprint.minWidth + layout.margin * 2);
168
+ footprint = legendFootprint(architectureLegendEntries, {
169
+ width: width - layout.margin * 2,
170
+ });
171
+ }
172
+ return [
173
+ width,
174
+ Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight),
175
+ ];
176
+ }
177
+
178
+ function resolvedViewBoxWidth(candidateBoundaries) {
179
+ if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
180
+ return arch.meta.viewBox[0];
181
+ }
182
+ return autoViewBoxFor(candidateBoundaries)[0];
183
+ }
184
+
185
+ function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
186
+ if (!enforcesBoundaryTitleComposition) return boundary;
187
+ const requiredWidth = boundaryLabelWidth(boundary.label, minimumFontSize)
188
+ + layout.boundaryLabelFrameInset * 2;
189
+ const extra = Math.max(0, requiredWidth - boundary.width);
190
+ if (!extra) return boundary;
191
+ return {
192
+ ...boundary,
193
+ x: boundary.x - extra / 2,
194
+ width: boundary.width + extra,
195
+ };
196
+ }
197
+
198
+ function measureBoundaryTitle(boundary, minimumFontSize) {
199
+ const availableWidth = Math.max(0, boundary.width - layout.boundaryLabelFrameInset * 2);
200
+ const units = textUnits(boundary.label);
201
+ const fitted = units > 0
202
+ ? (availableWidth - 10) / (units * 0.6)
203
+ : layout.boundaryLabelFontPreferred;
204
+ const preferredFontSize = Math.max(layout.boundaryLabelFontPreferred, minimumFontSize);
205
+ const fontSize = Math.max(
206
+ minimumFontSize,
207
+ Math.min(preferredFontSize, fitted),
208
+ );
209
+ const desiredWidth = boundaryLabelWidth(boundary.label, fontSize);
210
+ const height = Math.max(layout.boundaryLabelMaskHeight, Math.ceil(fontSize + 7));
211
+ return {
212
+ x: boundary.x + layout.boundaryLabelFrameInset,
213
+ y: boundary.memberTop
214
+ - layout.boundaryLabelClearance
215
+ - height,
216
+ width: Math.min(availableWidth, desiredWidth),
217
+ height,
218
+ fontSize,
219
+ minimumFontSize,
220
+ baselineOffset: fontSize + 4,
221
+ availableWidth,
222
+ minimumWidth: boundaryLabelWidth(boundary.label, minimumFontSize),
223
+ };
224
+ }
225
+
226
+ function horizontalOverlap(left, right) {
227
+ return left.x < right.x + right.width && left.x + left.width > right.x;
228
+ }
229
+
230
+ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
231
+ const placedTitles = [];
232
+ const measured = new Map();
233
+ const ordered = rawBoundaries
234
+ .map((boundary, index) => ({ boundary, index }))
235
+ .sort((left, right) => {
236
+ const areaDelta = left.boundary.width * left.boundary.height
237
+ - right.boundary.width * right.boundary.height;
238
+ return areaDelta || left.index - right.index;
239
+ });
240
+
241
+ for (const entry of ordered) {
242
+ const { index } = entry;
243
+ const boundary = expandBoundaryForReadableTitle(entry.boundary, minimumFontSize);
244
+ const title = measureBoundaryTitle(boundary, minimumFontSize);
245
+ let guard = 0;
246
+ while (guard < rawBoundaries.length + components.size + 1) {
247
+ guard += 1;
248
+ const blockers = [
249
+ ...placedTitles,
250
+ ...components.values(),
251
+ ].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
252
+ if (!blockers.length) break;
253
+ title.y = Math.min(
254
+ ...blockers.map((blocker) => blocker.y - layout.boundaryLabelRailGap - title.height),
255
+ );
256
+ }
257
+ placedTitles.push(title);
258
+ measured.set(index, { boundary, title });
259
+ }
260
+
261
+ return rawBoundaries.map((_boundary, index) => {
262
+ const { boundary, title } = measured.get(index);
263
+ const bottom = boundary.y + boundary.height;
264
+ // Profile-less schema-v1 inputs keep their legacy boundary geometry. A
265
+ // quality profile opts into the stricter title-composition contract and
266
+ // may expand the frame to contain an adapted title rail.
267
+ const y = enforcesBoundaryTitleComposition
268
+ ? Math.min(boundary.y, title.y - layout.boundaryLabelFrameInset)
269
+ : boundary.y;
270
+ return {
271
+ ...boundary,
272
+ y,
273
+ height: bottom - y,
274
+ title,
275
+ };
276
+ });
277
+ }
278
+
279
+ const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
280
+ function resolveBoundaryTitles() {
281
+ if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
282
+ return {
283
+ boundaries: layoutBoundaryTitles(rawBoundaries, layout.boundaryLabelFontMinimum),
284
+ readabilityProblem: null,
285
+ };
286
+ }
287
+
288
+ const maximumIterations = 32;
289
+ let candidateBoundaries = rawBoundaries;
290
+ for (let iteration = 0; iteration < maximumIterations; iteration += 1) {
291
+ const budgetViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
292
+ const minimumFontSize = Math.max(
293
+ layout.boundaryLabelFontMinimum,
294
+ minimumReadableSourceTextPx(budgetViewBoxWidth) + 1e-6,
295
+ );
296
+ const nextBoundaries = layoutBoundaryTitles(rawBoundaries, minimumFontSize);
297
+ const finalViewBoxWidth = resolvedViewBoxWidth(nextBoundaries);
298
+ const finalMinimumFontSize = Math.max(
299
+ layout.boundaryLabelFontMinimum,
300
+ minimumReadableSourceTextPx(finalViewBoxWidth),
301
+ );
302
+ if (minimumFontSize >= finalMinimumFontSize) {
303
+ return { boundaries: nextBoundaries, readabilityProblem: null };
304
+ }
305
+ candidateBoundaries = nextBoundaries;
306
+ }
307
+
308
+ const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
309
+ return {
310
+ boundaries: candidateBoundaries,
311
+ readabilityProblem: `[composition/desktop-readability] Boundary title layout did not converge after ${maximumIterations} iterations for the final ${finalViewBoxWidth}px viewBox — shorten boundary labels, provide a wider authored viewBox, or move wrapped components closer to the left edge.`,
312
+ };
313
+ }
314
+
315
+ const resolvedBoundaryTitles = resolveBoundaryTitles();
316
+ const boundaries = resolvedBoundaryTitles.boundaries;
317
+ const compositionFrames = boundaries.map((boundary, index) => ({
318
+ ...boundary,
319
+ id: boundary.id || index,
320
+ kind: boundary.kind || 'boundary',
321
+ radius: boundary.kind === 'security-group' ? 8 : 12,
322
+ }));
323
+
324
+ function componentContext(component) {
325
+ const scopes = boundaries
326
+ .filter((boundary) => asArray(boundary.wraps).includes(component.id))
327
+ .sort((a, b) => (b.width * b.height) - (a.width * a.height))
328
+ .map((boundary) => boundary.label);
329
+ return scopes.length ? scopes.join(' › ') : i18nText(arch.meta.locale, 'node.context.architecture');
330
+ }
331
+
332
+ // ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
333
+ const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries);
334
+ const legendY = () => viewBox[1] - 16;
335
+
336
+ // ---- Validation: mechanical correctness, never layout taste -----------------
337
+ function validateArchitecture() {
338
+ const problems = [];
339
+ if (resolvedBoundaryTitles.readabilityProblem) {
340
+ problems.push(resolvedBoundaryTitles.readabilityProblem);
341
+ }
342
+ const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership';
343
+ if (arch.schema_version !== 1) problems.push('Architecture files must set "schema_version": 1.');
344
+ if (arch.diagram_type !== 'architecture') problems.push('Architecture files must set "diagram_type": "architecture".');
345
+ if (!arch.meta?.title) problems.push('Architecture files must include meta.title.');
346
+ if (!Array.isArray(arch.components) || arch.components.length < 1) {
347
+ problems.push('Architecture diagrams need at least one component.');
348
+ }
349
+ if (arch.connections !== undefined && !Array.isArray(arch.connections)) problems.push('Architecture "connections" must be an array.');
350
+ if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.');
351
+ if (arch.cards !== undefined && !Array.isArray(arch.cards)) problems.push('Architecture "cards" must be an array.');
352
+ if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
353
+ if (grid) {
354
+ validateGridPlacement(arch, grid, problems);
355
+ } else {
356
+ for (const c of asArray(arch.components)) {
357
+ if (!Array.isArray(c.pos) || c.pos.length !== 2) {
358
+ problems.push(`Component "${c.id}" must include pos [x, y] when layout.mode is omitted (free placement).`);
359
+ }
360
+ }
361
+ }
362
+
363
+ for (const c of components.values()) {
364
+ if (!isFinitePoint(c.x, c.y, c.width, c.height)) {
365
+ problems.push(`Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`);
366
+ continue;
367
+ }
368
+ if (c.width <= 0 || c.height <= 0) {
369
+ problems.push(`Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`);
370
+ continue;
371
+ }
372
+ if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) {
373
+ problems.push(`Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`);
374
+ }
375
+ const estLabelW = textUnits(c.label) * 6.6;
376
+ if (estLabelW > c.width + 8) {
377
+ problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label or widen size.`);
378
+ }
379
+ const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
380
+ if (brandRailProblem) problems.push(brandRailProblem);
381
+ // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
382
+ // handles the ordinary case, this rejects what it cannot rescue.
383
+ const availableTextW = availableNodeTextWidth(c.width);
384
+ for (const [field, value, minimum] of [
385
+ ['Sublabel', c.sublabel, componentTextFit.sublabelMinimum],
386
+ ['Tag', c.tag, componentTextFit.tagMinimum],
387
+ ]) {
388
+ if (!value) continue;
389
+ const minimumW = minimumNodeTextWidth(value, minimum);
390
+ if (minimumW > availableTextW) {
391
+ problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but component "${c.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or widen size.`);
392
+ }
393
+ }
394
+ }
395
+
396
+ // Component overlap — the highest-traffic hand-placement failure mode.
397
+ const list = [...components.values()];
398
+ for (let i = 0; i < list.length; i += 1) {
399
+ for (let j = i + 1; j < list.length; j += 1) {
400
+ if (rectsOverlap(list[i], list[j], 8)) {
401
+ problems.push(`Components "${list[i].id}" and "${list[j].id}" are less than 8px apart — move one or shrink its size.\n${suggestComponentSeparation(list[i], list[j], 8)}`);
402
+ }
403
+ }
404
+ }
405
+
406
+ // Boundaries: every wrapped id must exist; the computed box must stay in view.
407
+ for (const boundary of asArray(arch.boundaries)) {
408
+ for (const id of asArray(boundary.wraps)) {
409
+ if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`);
410
+ }
411
+ }
412
+ const viewBoxRect = { x: 0, y: 0, width: viewBox[0], height: viewBox[1] };
413
+ for (const boundary of boundaries) {
414
+ if (!enforcesBoundaryTitleComposition) continue;
415
+ if (boundary.title.minimumWidth > boundary.title.availableWidth) {
416
+ problems.push(
417
+ `Boundary label "${boundary.label}" needs ~${Math.ceil(boundary.title.minimumWidth)}px to fit at the `
418
+ + `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px — `
419
+ + 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
420
+ );
421
+ }
422
+ if (!rectContains(boundary, boundary.title)) {
423
+ problems.push(
424
+ `Boundary label "${boundary.label}" extends outside its final frame — shorten the label or increase boundary pad.`,
425
+ );
426
+ }
427
+ if (!rectContains(viewBoxRect, boundary.title)) {
428
+ problems.push(
429
+ `Boundary label "${boundary.label}" extends outside the viewBox — move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
430
+ );
431
+ }
432
+ for (const component of components.values()) {
433
+ if (!rectsOverlap(boundary.title, component)) continue;
434
+ problems.push(
435
+ `Boundary label "${boundary.label}" overlaps component "${component.id}" — move the component, increase boundary title space, or shorten the label.`,
436
+ );
437
+ }
438
+ }
439
+ for (let leftIndex = 0; leftIndex < boundaries.length; leftIndex += 1) {
440
+ const left = boundaries[leftIndex];
441
+ const leftMembers = new Set(asArray(left.wraps));
442
+ for (let rightIndex = leftIndex + 1; rightIndex < boundaries.length; rightIndex += 1) {
443
+ const right = boundaries[rightIndex];
444
+ if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
445
+ problems.push(
446
+ `Boundary labels "${left.label}" and "${right.label}" overlap — shorten a label or increase boundary title space.`,
447
+ );
448
+ }
449
+ // Ordinary architecture boundaries are sets, not an implied ownership
450
+ // tree: orthogonal scopes such as runtime and compliance may share some
451
+ // components while each contains others. The opt-in deployment profile
452
+ // does promise hierarchical region/private-scope membership, so only it
453
+ // receives the stricter membership-to-frame containment contract.
454
+ if (!requiresNestedBoundaryMembership) continue;
455
+ const rightMembers = new Set(asArray(right.wraps));
456
+ const shared = [...leftMembers].filter((id) => rightMembers.has(id));
457
+ const leftNested = [...leftMembers].every((id) => rightMembers.has(id));
458
+ const rightNested = [...rightMembers].every((id) => leftMembers.has(id));
459
+ if (shared.length && !leftNested && !rightNested) {
460
+ const leftOnly = [...leftMembers].filter((id) => !rightMembers.has(id));
461
+ const rightOnly = [...rightMembers].filter((id) => !leftMembers.has(id));
462
+ problems.push(
463
+ `Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
464
+ + `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
465
+ + `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
466
+ + `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) — `
467
+ + 'keep one boundary fully nested by removing outside members, or split the boundary.',
468
+ );
469
+ continue;
470
+ }
471
+
472
+ if (!rectsOverlap(left, right)) continue;
473
+ const leftContainsRight = rectContains(left, right);
474
+ const rightContainsLeft = rectContains(right, left);
475
+ if (!leftContainsRight && !rightContainsLeft) {
476
+ problems.push(
477
+ `Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap — `
478
+ + 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
479
+ );
480
+ continue;
481
+ }
482
+
483
+ if (!shared.length) {
484
+ problems.push(
485
+ `Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint — `
486
+ + 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
487
+ );
488
+ continue;
489
+ }
490
+
491
+ const containmentMatchesMembership = (leftNested && rightContainsLeft)
492
+ || (rightNested && leftContainsRight);
493
+ if (!containmentMatchesMembership) {
494
+ problems.push(
495
+ `Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership — `
496
+ + 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
497
+ );
498
+ }
499
+ }
500
+ }
501
+ for (const b of boundaries) {
502
+ if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
503
+ problems.push(`Boundary "${b.label}" extends outside the viewBox — its members sit too close to the canvas edge; add margin or enlarge meta.viewBox.`);
504
+ }
505
+ }
506
+
507
+ for (const conn of asArray(arch.connections)) {
508
+ if (!components.has(conn.from)) problems.push(`Connection "${conn.label || conn.from}" references unknown source "${conn.from}".`);
509
+ if (!components.has(conn.to)) problems.push(`Connection "${conn.label || conn.to}" references unknown target "${conn.to}".`);
510
+ if (components.has(conn.from) && components.has(conn.to)) {
511
+ const routed = pathFor(conn);
512
+ const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
513
+ const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
514
+ if (distance < 24) problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) — place its components farther apart.`);
515
+ }
516
+ }
517
+
518
+ problems.push(...cleanEndpointSideProblems({
519
+ relations: arch.connections,
520
+ endpointIds: new Set(components.keys()),
521
+ pathFor,
522
+ diagramType: 'architecture',
523
+ relationCollection: 'connections',
524
+ fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
525
+ toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
526
+ routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide with perpendicular via segments',
527
+ }));
528
+ problems.push(...cleanFlowProblems({
529
+ relations: arch.connections,
530
+ endpointIds: new Set(components.keys()),
531
+ obstacles: components.values(),
532
+ pathFor,
533
+ diagramType: 'architecture',
534
+ relationCollection: 'connections',
535
+ obstacleKind: 'component',
536
+ profile: arch.meta?.quality_profile,
537
+ routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
538
+ }));
539
+ problems.push(...cleanCrossingProblems({
540
+ relations: arch.connections,
541
+ endpointIds: new Set(components.keys()),
542
+ pathFor,
543
+ diagramType: 'architecture',
544
+ relationCollection: 'connections',
545
+ profile: arch.meta?.quality_profile,
546
+ routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
547
+ }));
548
+ problems.push(...cleanAmbiguousCorridorProblems({
549
+ relations: arch.connections,
550
+ endpointIds: new Set(components.keys()),
551
+ pathFor,
552
+ diagramType: 'architecture',
553
+ relationCollection: 'connections',
554
+ profile: arch.meta?.quality_profile,
555
+ routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge'
556
+ }));
557
+ problems.push(...cleanBorderRunProblems({
558
+ relations: arch.connections,
559
+ endpointIds: new Set(components.keys()),
560
+ frames: compositionFrames,
561
+ pathFor,
562
+ diagramType: 'architecture',
563
+ relationCollection: 'connections',
564
+ profile: arch.meta?.quality_profile,
565
+ routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border'
566
+ }));
567
+ problems.push(...cleanRouteRhythmProblems({
568
+ relations: arch.connections,
569
+ endpointIds: new Set(components.keys()),
570
+ pathFor,
571
+ diagramType: 'architecture',
572
+ relationCollection: 'connections',
573
+ profile: arch.meta?.quality_profile,
574
+ routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
575
+ }));
576
+
577
+ // Connection labels must not land on top of components.
578
+ const labelRects = [];
579
+ for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
580
+ if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
581
+ const [lx, ly] = labelPoint(conn, pathFor(conn).points);
582
+ const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
583
+ labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
584
+ }
585
+ for (const rect of labelRects) {
586
+ for (const c of components.values()) {
587
+ if (rectsOverlap(rect, c, -2)) {
588
+ problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`);
589
+ }
590
+ }
591
+ if (enforcesBoundaryTitleComposition) {
592
+ for (const boundary of boundaries) {
593
+ if (!rectsOverlap(boundary.title, rect)) continue;
594
+ problems.push(
595
+ `Boundary label "${boundary.label}" overlaps connection label "${rect.label}" — move the boundary title rail by adjusting wrapped component positions, or move the connection label with labelAt/labelDx/labelDy/labelSegment.`,
596
+ );
597
+ }
598
+ }
599
+ }
600
+ problems.push(...cleanLabelRouteClearanceProblems({
601
+ relations: arch.connections,
602
+ labels: labelRects,
603
+ endpointIds: new Set(components.keys()),
604
+ pathFor,
605
+ diagramType: 'architecture',
606
+ relationCollection: 'connections',
607
+ profile: arch.meta?.quality_profile,
608
+ }));
609
+
610
+ if (problems.length) {
611
+ throwDiagnosticProblems('Architecture layout validation failed', problems, {
612
+ subject: { diagramType: 'architecture' },
613
+ });
614
+ }
615
+ }
616
+
617
+ function buildLayoutReport() {
618
+ const labels = [];
619
+ for (const conn of asArray(arch.connections)) {
620
+ if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
621
+ const [lx, ly] = labelPoint(conn, pathFor(conn).points);
622
+ const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
623
+ labels.push({
624
+ text: conn.label,
625
+ x: Math.round(lx - w / 2),
626
+ y: Math.round(ly - 10),
627
+ width: Math.round(w),
628
+ height: 14,
629
+ labelAt: [Math.round(lx), Math.round(ly)],
630
+ });
631
+ }
632
+ return {
633
+ ok: true,
634
+ diagram_type: 'architecture',
635
+ layout: grid ? { mode: 'grid', ...grid } : { mode: 'free' },
636
+ viewBox,
637
+ components: [...components.values()].map(componentBox),
638
+ boundaries: boundaries.map(boundaryBox),
639
+ connections: asArray(arch.connections)
640
+ .filter((conn) => components.has(conn.from) && components.has(conn.to))
641
+ .map((conn) => {
642
+ const routed = pathFor(conn);
643
+ const labelAt = conn.label ? labelPoint(conn, routed.points) : null;
644
+ return connectionPath(conn, routed, labelAt);
645
+ }),
646
+ labels,
647
+ };
648
+ }
649
+
650
+ // ---- Connection routing ------------------------------------------------------
651
+ function routeClearsComponents(conn, points, clearance = 2) {
652
+ const endpointIds = new Set([conn.from, conn.to]);
653
+ for (const component of components.values()) {
654
+ if (endpointIds.has(component.id)) continue;
655
+ for (let index = 0; index < points.length - 1; index += 1) {
656
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
657
+ return false;
658
+ }
659
+ }
660
+ }
661
+ return true;
662
+ }
663
+
664
+ function routeClearsEndpointComponents(points, from, to) {
665
+ const lastSegment = points.length - 2;
666
+ for (let index = 0; index <= lastSegment; index += 1) {
667
+ const segment = { start: points[index], end: points[index + 1] };
668
+ if (index > 0 && segmentIntersectsRect(segment, from)) return false;
669
+ if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
670
+ }
671
+ return true;
672
+ }
673
+
674
+ const OUTWARD_SIDE_VECTOR = {
675
+ left: [-1, 0],
676
+ right: [1, 0],
677
+ top: [0, -1],
678
+ bottom: [0, 1],
679
+ };
680
+
681
+ function outwardStub(point, side, distance = 24) {
682
+ const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
683
+ return [point[0] + dx * distance, point[1] + dy * distance];
684
+ }
685
+
686
+ function collinearBacktrack(a, b, c) {
687
+ const first = [b[0] - a[0], b[1] - a[1]];
688
+ const second = [c[0] - b[0], c[1] - b[1]];
689
+ const cross = first[0] * second[1] - first[1] * second[0];
690
+ const dot = first[0] * second[0] + first[1] * second[1];
691
+ return Math.abs(cross) <= 0.0001 && dot < -0.0001;
692
+ }
693
+
694
+ function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
695
+ const startStub = outwardStub(start, fromSide);
696
+ const endStub = outwardStub(end, toSide);
697
+ const rawCandidates = [];
698
+ const minimumBridge = 16;
699
+ const verticalSides = new Set(['top', 'bottom']);
700
+ const horizontalSides = new Set(['left', 'right']);
701
+
702
+ // Port spreading can leave parallel-side anchors only a few pixels apart.
703
+ // Route through a bounded outside channel so we keep both endpoint normals
704
+ // without introducing a tiny, noisy connector between the two stubs.
705
+ if (verticalSides.has(fromSide) && verticalSides.has(toSide)
706
+ && Math.abs(start[0] - end[0]) < minimumBridge) {
707
+ for (const channelX of [
708
+ Math.max(start[0], end[0]) + minimumBridge,
709
+ Math.min(start[0], end[0]) - minimumBridge,
710
+ ]) {
711
+ rawCandidates.push([
712
+ startStub,
713
+ [channelX, startStub[1]],
714
+ [channelX, endStub[1]],
715
+ endStub,
716
+ ]);
717
+ }
718
+ }
719
+ if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
720
+ && Math.abs(start[1] - end[1]) < minimumBridge) {
721
+ for (const channelY of [
722
+ Math.max(start[1], end[1]) + minimumBridge,
723
+ Math.min(start[1], end[1]) - minimumBridge,
724
+ ]) {
725
+ rawCandidates.push([
726
+ startStub,
727
+ [startStub[0], channelY],
728
+ [endStub[0], channelY],
729
+ endStub,
730
+ ]);
731
+ }
732
+ }
733
+
734
+ rawCandidates.push(
735
+ [startStub, [endStub[0], startStub[1]], endStub],
736
+ [startStub, [startStub[0], endStub[1]], endStub],
737
+ );
738
+ return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
739
+ .filter((points) => points.length >= 2)
740
+ .filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
741
+ .filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
742
+ .filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
743
+ .map((points) => points.slice(1, -1));
744
+ }
745
+
746
+ const AUTOMATIC_PORT_CORNER_GUTTER = 16;
747
+ const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16;
748
+
749
+ function portHasCornerClearance(rect, side, point) {
750
+ if (side === 'left' || side === 'right') {
751
+ const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2);
752
+ return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
753
+ }
754
+ if (side === 'top' || side === 'bottom') {
755
+ const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2);
756
+ return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
757
+ }
758
+ return false;
759
+ }
760
+
761
+ function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
762
+ const hasExplicitGeometry = (
763
+ conn.via
764
+ || (conn.route && conn.route !== 'auto')
765
+ || conn.channelX !== undefined
766
+ || conn.channelY !== undefined
767
+ || conn.labelAt
768
+ );
769
+ const horizontallyFacing = (
770
+ (fromSide === 'right' && toSide === 'left')
771
+ || (fromSide === 'left' && toSide === 'right')
772
+ );
773
+ const verticallyFacing = (
774
+ (fromSide === 'bottom' && toSide === 'top')
775
+ || (fromSide === 'top' && toSide === 'bottom')
776
+ );
777
+ if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
778
+
779
+ const fromSpread = Boolean(ports?.from);
780
+ const toSpread = Boolean(ports?.to);
781
+ if (fromSpread && toSpread) return { start, end };
782
+ const hasExplicitSides = (
783
+ (conn.fromSide && conn.fromSide !== 'auto')
784
+ || (conn.toSide && conn.toSide !== 'auto')
785
+ );
786
+ if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
787
+
788
+ const alignmentDelta = horizontallyFacing
789
+ ? Math.abs(start[1] - end[1])
790
+ : Math.abs(start[0] - end[0]);
791
+ if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end };
792
+
793
+ // Keep the shared endpoint's distinct spread slot and move only the
794
+ // relationship's unshared endpoint onto that axis. With no spread endpoint,
795
+ // retain the existing least-movement choice between the two facing sides.
796
+ // If both endpoints are shared, preserve the outside bridge so no competing
797
+ // port is silently collapsed.
798
+ const alignEndToStart = horizontallyFacing
799
+ ? { start, end: [end[0], start[1]] }
800
+ : { start, end: [start[0], end[1]] };
801
+ const alignStartToEnd = horizontallyFacing
802
+ ? { start: [start[0], end[1]], end }
803
+ : { start: [end[0], start[1]], end };
804
+ const candidates = fromSpread
805
+ ? [alignEndToStart]
806
+ : toSpread
807
+ ? [alignStartToEnd]
808
+ : [alignEndToStart, alignStartToEnd];
809
+ for (const candidate of candidates) {
810
+ const points = [candidate.start, candidate.end];
811
+ if (portHasCornerClearance(from, fromSide, candidate.start)
812
+ && portHasCornerClearance(to, toSide, candidate.end)
813
+ && routeHonorsEndpointSides(points, fromSide, toSide)
814
+ && routeClearsEndpointComponents(points, from, to)
815
+ && routeClearsComponents(conn, points)) {
816
+ return candidate;
817
+ }
818
+ }
819
+ return { start, end };
820
+ }
821
+
822
+ function routeVia(conn, from, to, start, end, fromSide, toSide) {
823
+ if (conn.via) return conn.via;
824
+ switch (conn.route || 'auto') {
825
+ case 'straight':
826
+ return [];
827
+ case 'orthogonal-h': {
828
+ const midX = (start[0] + end[0]) / 2;
829
+ return [[midX, start[1]], [midX, end[1]]];
830
+ }
831
+ case 'orthogonal-v': {
832
+ const midY = (start[1] + end[1]) / 2;
833
+ return [[start[0], midY], [end[0], midY]];
834
+ }
835
+ case 'auto':
836
+ default: {
837
+ // Direct line unless the anchors are clearly orthogonal-friendly.
838
+ const deltaX = Math.abs(start[0] - end[0]);
839
+ const deltaY = Math.abs(start[1] - end[1]);
840
+ if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return [];
841
+
842
+ const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
843
+ accept: (points) => (
844
+ routeClearsEndpointComponents(points, from, to)
845
+ && routeClearsComponents(conn, points)
846
+ ),
847
+ });
848
+ if (rhythmBridge) return rhythmBridge.slice(1, -1);
849
+
850
+ // Automatic port spreading can leave otherwise aligned endpoints only a
851
+ // few pixels apart. A midpoint route would split that tiny difference
852
+ // into two unreadable endpoint stubs, so take a bounded outside channel
853
+ // when both anchors sit on parallel component sides.
854
+ const minimumStub = 8;
855
+ const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
856
+ const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
857
+ if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
858
+ const outsideChannels = [
859
+ Math.max(start[0], end[0]) + minimumStub * 2,
860
+ Math.min(start[0], end[0]) - minimumStub * 2,
861
+ ];
862
+ for (const channelX of outsideChannels) {
863
+ const candidate = [[channelX, start[1]], [channelX, end[1]]];
864
+ const points = [start, ...candidate, end];
865
+ if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
866
+ }
867
+ }
868
+
869
+ const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
870
+ const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
871
+ if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
872
+ const outsideChannels = [
873
+ Math.max(start[1], end[1]) + minimumStub * 2,
874
+ Math.min(start[1], end[1]) - minimumStub * 2,
875
+ ];
876
+ for (const channelY of outsideChannels) {
877
+ const candidate = [[start[0], channelY], [end[0], channelY]];
878
+ const points = [start, ...candidate, end];
879
+ if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
880
+ }
881
+ }
882
+
883
+ const midX = (start[0] + end[0]) / 2;
884
+ const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
885
+ const midY = (start[1] + end[1]) / 2;
886
+ const verticalFirst = [[start[0], midY], [end[0], midY]];
887
+ const candidates = [horizontalFirst, verticalFirst];
888
+ const sideSafe = candidates.filter((candidate) => (
889
+ routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
890
+ ));
891
+ const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
892
+ const nearParallelPorts = (
893
+ ((fromSide === 'top' || fromSide === 'bottom')
894
+ && (toSide === 'top' || toSide === 'bottom')
895
+ && deltaX < minimumStub * 2)
896
+ || ((fromSide === 'left' || fromSide === 'right')
897
+ && (toSide === 'left' || toSide === 'right')
898
+ && deltaY < minimumStub * 2)
899
+ );
900
+ const ordered = [
901
+ ...(nearParallelPorts ? sideAware : sideSafe),
902
+ ...(nearParallelPorts ? sideSafe : sideAware),
903
+ ...candidates.filter((candidate) => !sideSafe.includes(candidate)),
904
+ ];
905
+ for (const candidate of ordered) {
906
+ const points = [start, ...candidate, end];
907
+ if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate;
908
+ }
909
+
910
+ // Both bounded doglegs are blocked. Keep the best endpoint-safe route
911
+ // when one exists so the universal Clean Flow gate reports the actual
912
+ // obstacle; otherwise preserve the historical deterministic fallback
913
+ // and let the endpoint-direction gate explain the side mismatch.
914
+ return sideSafe[0] || sideAware[0] || horizontalFirst;
915
+ }
916
+ }
917
+ }
918
+
919
+ const pathCache = new Map();
920
+ const automaticPorts = automaticPortSpread(arch.connections, components);
921
+ function connectionSides(conn) {
922
+ const from = components.get(conn.from);
923
+ const to = components.get(conn.to);
924
+ return {
925
+ fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)),
926
+ toSide: chosenSide(conn.toSide, defaultToSide(from, to)),
927
+ };
928
+ }
929
+
930
+ function connectionEndpointSide(conn, endpoint) {
931
+ const field = endpoint === 'source' ? 'fromSide' : 'toSide';
932
+ if (conn[field] && conn[field] !== 'auto') return conn[field];
933
+ return connectionSides(conn)[field];
934
+ }
935
+
936
+ function pathFor(conn) {
937
+ if (pathCache.has(conn)) return pathCache.get(conn);
938
+ const from = components.get(conn.from);
939
+ const to = components.get(conn.to);
940
+ const ports = automaticPorts.get(conn);
941
+ const { fromSide, toSide } = connectionSides(conn);
942
+ const baseStart = ports?.from || anchor(from, fromSide);
943
+ const baseEnd = ports?.to || anchor(to, toSide);
944
+ const { start, end } = alignFacingPorts(
945
+ conn,
946
+ from,
947
+ to,
948
+ baseStart,
949
+ baseEnd,
950
+ fromSide,
951
+ toSide,
952
+ ports,
953
+ );
954
+ const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end];
955
+ const routed = { d: roundedPath(points, 8), points };
956
+ pathCache.set(conn, routed);
957
+ return routed;
958
+ }
959
+
960
+ // ---- Rendering ---------------------------------------------------------------
961
+ function renderBoundaryFrame(b, index) {
962
+ const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
963
+ const rx = b.kind === 'security-group' ? 8 : 12;
964
+ return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-id="${index}" data-composition-frame-label="${esc(b.label)}" x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"/>`;
965
+ }
966
+
967
+ function renderBoundaryLabel(b, index) {
968
+ const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
969
+ return ` <g data-graph-role="structural-frame-label" data-composition-frame-id="${index}" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-label="${esc(b.label)}">
970
+ <rect data-graph-role="structural-frame-label-mask" x="${b.title.x}" y="${b.title.y}" width="${b.title.width}" height="${b.title.height}" rx="3" class="c-mask"/>
971
+ <text data-boundary-label="" x="${b.title.x + 4}" y="${b.title.y + b.title.baselineOffset}" class="${labelCls}" font-size="${b.title.fontSize}" font-weight="600">${esc(b.label)}</text>
972
+ </g>`;
973
+ }
974
+
975
+ function renderConnectionPath(conn, index) {
976
+ const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
977
+ const routed = pathFor(conn);
978
+ const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
979
+ return ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
980
+ }
981
+
982
+ function renderConnectionLabel(conn, index) {
983
+ if (!conn.label) return '';
984
+ const [lx, ly] = labelPoint(conn, pathFor(conn).points);
985
+ const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
986
+ return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
987
+ <rect x="${lx - w / 2}" y="${ly - 10}" width="${w}" height="14" rx="3" class="c-mask"/>
988
+ <text x="${lx}" y="${ly}" class="${variantAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
989
+ </g>`;
990
+ }
991
+
992
+ function renderComponent(c) {
993
+ const fill = componentFill[c.type] || 'c-external';
994
+ const accent = componentText[c.type] || 't-muted';
995
+ const cx = c.cx;
996
+ const hasSub = c.sublabel != null && c.sublabel !== '';
997
+ const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4;
998
+ const sub = hasSub
999
+ ? `\n <text data-detail="context" x="${cx}" y="${c.y + c.height / 2 + 14}" class="t-muted" font-size="${fittedNodeFontSize(c.sublabel, c.width, componentTextFit.sublabelPreferred, componentTextFit.sublabelMinimum)}" text-anchor="middle">${esc(c.sublabel)}</text>`
1000
+ : '';
1001
+ const tag = c.tag
1002
+ ? `\n <text data-detail="fine" x="${cx}" y="${c.y + c.height - 8}" class="${accent}" font-size="${fittedNodeFontSize(c.tag, c.width, componentTextFit.tagPreferred, componentTextFit.tagMinimum)}" text-anchor="middle">${esc(c.tag)}</text>`
1003
+ : '';
1004
+ const brand = renderBrandMark(c, { x: c.x + c.width - 22, y: c.y + 6 });
1005
+ const labelFontSize = fittedNodeFontSize(c.label, brandLabelFitWidth(c, c.width), 11, 8);
1006
+ const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
1007
+ return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
1008
+ ${focusNodeTitle(c.label, passport)}
1009
+ <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="c-mask"/>
1010
+ <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="${fill}"${animateAttr(arch.meta, 'node', componentSteps.get(c.id))} stroke-width="1.5"/>
1011
+ ${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
1012
+ <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${cx}" y="${labelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(c.label)}</text>${sub}${tag}
1013
+ </g>`;
1014
+ }
1015
+
1016
+ function renderLegend() {
1017
+ const entries = architectureLegendEntries;
1018
+ const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
1019
+ pointsFor: (connection) => pathFor(connection).points,
1020
+ labelRectFor: (connection) => {
1021
+ if (!connection.label) return null;
1022
+ const [x, y] = labelPoint(connection, pathFor(connection).points);
1023
+ const width = Math.max(30, textUnits(connection.label) * 4.8 + 10);
1024
+ return { x: x - width / 2, y: y - 10, width, height: 14 };
1025
+ },
1026
+ });
1027
+ const contentBottom = Math.max(
1028
+ 0,
1029
+ ...[...components.values()].map((component) => component.y + component.height),
1030
+ ...boundaries.map((boundary) => boundary.y + boundary.height),
1031
+ );
1032
+ return renderResolvedLegend({
1033
+ entries,
1034
+ locale: arch.meta.locale,
1035
+ layout: {
1036
+ x: layout.margin,
1037
+ baselineY: legendY(),
1038
+ width: viewBox[0] - layout.margin * 2,
1039
+ minTitleY: contentBottom + 8,
1040
+ obstacles: relationshipObstacles,
1041
+ unfit: arch.meta?.legend === undefined ? 'hide' : 'error',
1042
+ diagramType: 'architecture',
1043
+ },
1044
+ renderSwatch: (entry) => `<rect x="${entry.x}" y="${entry.baseline - 9}" width="16" height="10" rx="2.5" class="${componentFill[entry.kind] || 'c-external'}" stroke-width="1"/>`,
1045
+ });
1046
+ }
1047
+
1048
+ function renderSvg() {
1049
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta, 'architecture diagram')}>
1050
+ ${svgAccessibleText(arch.meta, 'architecture')}
1051
+ ${renderDefinitions()}
1052
+
1053
+ <!-- Background Grid -->
1054
+ <rect width="100%" height="100%" fill="url(#grid)" />
1055
+
1056
+ <!-- Boundaries (behind everything) -->
1057
+ ${boundaries.map(renderBoundaryFrame).join('\n\n')}
1058
+
1059
+ <!-- Connection paths (before components for correct z-order) -->
1060
+ ${asArray(arch.connections).map(renderConnectionPath).join('\n')}
1061
+
1062
+ <!-- Components -->
1063
+ ${[...components.values()].map(renderComponent).join('\n\n')}
1064
+
1065
+ <!-- Connection labels -->
1066
+ ${asArray(arch.connections).map(renderConnectionLabel).join('\n')}
1067
+
1068
+ <!-- Boundary labels (foreground masks keep routes out of titles) -->
1069
+ ${boundaries.map(renderBoundaryLabel).join('\n\n')}
1070
+
1071
+ <!-- Legend -->
1072
+ ${renderLegend()}
1073
+ </svg>`;
1074
+ }
1075
+
1076
+ validateArchitecture();
1077
+ if (layoutJsonMode) {
1078
+ console.log(JSON.stringify(buildLayoutReport(), null, 2));
1079
+ process.exit(0);
1080
+ }
1081
+ writeDiagram({
1082
+ outPath,
1083
+ template,
1084
+ diagramType: 'architecture',
1085
+ meta: arch.meta,
1086
+ svg: renderSvg(),
1087
+ cards: arch.cards,
1088
+ sourceEvidence,
1089
+ });