render-architecture.mjs
1204 lines · archify/renderers/architecture/render-architecture.mjs at a406594
1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4import { NODE_CARD_ATTR, nodeCardStyle } from '../shared/node-card-style.mjs';
5import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
6import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
7import { rendererFailure, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
8import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
9import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
10import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
11import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
12import { translateMessage as i18nText } from '../shared/i18n.mjs';
13import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
14import { createRouter } from './routing.mjs';
15import { placeAutomaticLabels, reservedLabelRect } from '../shared/automatic-labels.mjs';
16import { cleanRouteDetourProblems } from '../shared/route-quality.mjs';
17import {
18 asArray,
19 isFinitePoint,
20 rectsOverlap,
21 segmentIntersectsRect,
22 cleanEndpointSideProblems,
23 cleanFlowProblems,
24 cleanCrossingProblems,
25 cleanAmbiguousCorridorProblems,
26 collectArrowheadCollisions,
27 cleanBorderRunProblems,
28 cleanRouteRhythmProblems,
29 cleanLabelRouteClearanceProblems,
30 cleanLabelCanvasContainmentProblems,
31 suggestLabelObstacleFix,
32 componentSeparationOptions,
33 suggestComponentSeparation,
34 polylinePath,
35 routePointsValue,
36 authoredStraightRouteAttrs,
37 labelPoint,
38 componentFill,
39 componentText,
40 arrowClassMap,
41 edgeLabelAccent,
42} from '../shared/geometry.mjs';
44const componentTextFit = {
45 sublabelPreferred: 9,
46 sublabelMinimum: 6,
47 tagPreferred: 7,
48 tagMinimum: 6,
49};
51const __dirname = path.dirname(fileURLToPath(import.meta.url));
52const layoutJsonMode = process.argv.includes('--layout-json');
53const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
54const { diagram: arch, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
55 rendererDir: __dirname,
56 diagramType: 'architecture',
57 defaultExample: 'web-app.architecture.json',
58 argv: cliArgs,
59});
61const grid = gridLayout(arch);
63const layout = {
64 defaultW: 120,
65 defaultH: 60,
66 margin: 40,
67 // Boundary padding — the 30/50 rule that was a hand-arithmetic footgun
68 // (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
69 boundaryPad: 30,
70 boundaryExtraBottom: 20,
71 boundaryLabelBaseline: 18,
72 boundaryLabelClearance: 4,
73 boundaryLabelFontPreferred: 9,
74 boundaryLabelFontMinimum: 6,
75 boundaryLabelMaskHeight: 16,
76 boundaryLabelRailGap: 2,
77 boundaryLabelFrameInset: 4,
78 legendH: 28,
79};
81const LEGEND_CATALOG = [
82 'frontend',
83 'backend',
84 'database',
85 'cloud',
86 'security',
87 'messagebus',
88 'external',
89].map((kind) => ({ kind, label: i18nText(arch.meta.locale, `legend.architecture.${kind}`) }));
91// ---- Measure components from free coordinates --------------------------------
92function measureComponent(c) {
93 const [x, y] = resolveComponentPos(c, grid);
94 const [w, h] = Array.isArray(c.size) ? c.size : [layout.defaultW, layout.defaultH];
95 return { ...c, x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 };
96}
98const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)]));
99const enforcesBoundaryTitleComposition = Boolean(arch.meta?.quality_profile);
100const componentSteps = new Map();
101for (const [index, conn] of asArray(arch.connections).entries()) {
102 if (!componentSteps.has(conn.from)) componentSteps.set(conn.from, index);
103 if (!componentSteps.has(conn.to)) componentSteps.set(conn.to, index + 1);
104}
105for (const [index, c] of asArray(arch.components).entries()) {
106 if (!componentSteps.has(c.id)) componentSteps.set(c.id, index);
107}
109// ---- Boundaries computed from the `wraps` id list ---------------------------
110function boundaryRect(boundary) {
111 const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean);
112 if (!members.length) return null;
113 let minX = Infinity;
114 let minY = Infinity;
115 let maxX = -Infinity;
116 let maxY = -Infinity;
117 for (const member of members) {
118 minX = Math.min(minX, member.x);
119 minY = Math.min(minY, member.y);
120 maxX = Math.max(maxX, member.x + member.width);
121 maxY = Math.max(maxY, member.y + member.height);
122 }
123 const pad = boundary.pad ?? layout.boundaryPad;
124 const topPad = Math.max(
125 pad,
126 layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
127 // Routes must avoid the title rail that the final frame will contain.
128 enforcesBoundaryTitleComposition
129 ? layout.boundaryLabelMaskHeight + layout.boundaryLabelClearance + layout.boundaryLabelFrameInset
130 : 0,
131 );
132 return {
133 ...boundary,
134 x: minX - pad,
135 y: minY - topPad,
136 width: maxX - minX + pad * 2,
137 height: maxY - minY + topPad + layout.boundaryExtraBottom,
138 memberTop: minY,
139 };
140}
142function rectContains(outer, inner) {
143 const epsilon = 1e-9;
144 return outer.x <= inner.x + epsilon
145 && outer.y <= inner.y + epsilon
146 && outer.x + outer.width + epsilon >= inner.x + inner.width
147 && outer.y + outer.height + epsilon >= inner.y + inner.height;
148}
150function boundaryLabelWidth(label, fontSize) {
151 return Math.max(30, textUnits(label) * fontSize * 0.6 + 10);
152}
154const architectureLegendEntries = resolveLegend(
155 arch.meta?.legend,
156 LEGEND_CATALOG,
157 new Set([...components.values()].map((component) => component.type)),
158);
160// One source for connection label geometry: the rect the containment rule
161// measures is the rect the SVG mask draws, the auto canvas covers, the legend
162// avoids, and the layout report publishes.
163const resolvedLabelPoints = new Map();
164function connectionLabelBox(conn) {
165 if (!conn.label) return null;
166 return connectionLabelBoxAt(conn, resolvedLabelPoints.get(conn) || labelPoint(conn, pathFor(conn).points));
167}
169function connectionLabelBoxAt(conn, [lx, ly]) {
170 const width = Math.max(30, textUnits(conn.label) * 4.8 + 10);
171 return { x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly };
172}
174function connectionLabelRects() {
175 const rects = [];
176 for (const [relationIndex, conn] of asArray(arch.connections).entries()) {
177 if (!components.has(conn.from) || !components.has(conn.to)) continue;
178 const box = connectionLabelBox(conn);
179 if (!box) continue;
180 rects.push({ relation: conn, relationIndex, label: conn.label, ...box });
181 }
182 return rects;
183}
185function autoViewBoxFor(candidateBoundaries, extraRects = []) {
186 let maxX = 0;
187 let maxY = 0;
188 for (const rects of [components.values(), candidateBoundaries, extraRects]) {
189 for (const rect of rects) {
190 maxX = Math.max(maxX, rect.x + rect.width);
191 maxY = Math.max(maxY, rect.y + rect.height);
192 }
193 }
194 let width = Math.ceil(maxX + layout.margin);
195 let footprint = legendFootprint(architectureLegendEntries, {
196 width: Math.max(1, width - layout.margin * 2),
197 });
198 if (footprint.minWidth > width - layout.margin * 2) {
199 width = Math.ceil(footprint.minWidth + layout.margin * 2);
200 footprint = legendFootprint(architectureLegendEntries, {
201 width: width - layout.margin * 2,
202 });
203 }
204 return [
205 width,
206 Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight),
207 ];
208}
210function resolvedViewBoxWidth(candidateBoundaries) {
211 if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
212 return arch.meta.viewBox[0];
213 }
214 return autoViewBoxFor(candidateBoundaries, connectionGeometry)[0];
215}
217function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
218 if (!enforcesBoundaryTitleComposition) return boundary;
219 const requiredWidth = boundaryLabelWidth(boundary.label, minimumFontSize)
220 + layout.boundaryLabelFrameInset * 2;
221 const extra = Math.max(0, requiredWidth - boundary.width);
222 if (!extra) return boundary;
223 return {
224 ...boundary,
225 x: boundary.x - extra / 2,
226 width: boundary.width + extra,
227 };
228}
230function measureBoundaryTitle(boundary, minimumFontSize) {
231 const availableWidth = Math.max(0, boundary.width - layout.boundaryLabelFrameInset * 2);
232 const units = textUnits(boundary.label);
233 const fitted = units > 0
234 ? (availableWidth - 10) / (units * 0.6)
235 : layout.boundaryLabelFontPreferred;
236 const preferredFontSize = Math.max(layout.boundaryLabelFontPreferred, minimumFontSize);
237 const fontSize = Math.max(
238 minimumFontSize,
239 Math.min(preferredFontSize, fitted),
240 );
241 const desiredWidth = boundaryLabelWidth(boundary.label, fontSize);
242 const height = Math.max(layout.boundaryLabelMaskHeight, Math.ceil(fontSize + 7));
243 return {
244 x: boundary.x + layout.boundaryLabelFrameInset,
245 y: boundary.memberTop
246 - layout.boundaryLabelClearance
247 - height,
248 width: Math.min(availableWidth, desiredWidth),
249 height,
250 fontSize,
251 minimumFontSize,
252 baselineOffset: fontSize + 4,
253 availableWidth,
254 minimumWidth: boundaryLabelWidth(boundary.label, minimumFontSize),
255 };
256}
258function horizontalOverlap(left, right) {
259 return left.x < right.x + right.width && left.x + left.width > right.x;
260}
262// A route through a title disappears under its mask and reads as struck-through
263// text. Keep the left rail position when it is clear; otherwise slide the title
264// along its rail to the nearest clear position inside the frame.
265let titleRouteSegments = null;
266function slideTitleOffRoutes(boundary, title, placedTitles) {
267 if (!enforcesBoundaryTitleComposition) return;
268 titleRouteSegments ||= asArray(arch.connections)
269 .filter((conn) => components.has(conn.from) && components.has(conn.to))
270 .flatMap((conn) => {
271 const points = pathFor(conn).points;
272 return points.slice(1).map((end, index) => ({ start: points[index], end }));
273 });
274 const clear = (x) => {
275 const rect = { ...title, x };
276 return !titleRouteSegments.some((segment) => segmentIntersectsRect(segment, rect, 2))
277 && ![...placedTitles, ...components.values(), ...connectionLabels].some((other) => rectsOverlap(rect, other));
278 };
279 if (clear(title.x)) return;
280 const left = boundary.x + layout.boundaryLabelFrameInset;
281 const right = boundary.x + boundary.width - layout.boundaryLabelFrameInset - title.width;
282 const band = titleRouteSegments.filter(({ start, end }) => (
283 Math.max(start[1], end[1]) >= title.y - 2 && Math.min(start[1], end[1]) <= title.y + title.height + 2
284 ));
285 const labelBlockers = connectionLabels.filter((label) => (
286 label.y < title.y + title.height && label.y + label.height > title.y
287 ));
288 const x = [right, ...band.flatMap(({ start, end }) => [
289 Math.max(start[0], end[0]) + 8, Math.min(start[0], end[0]) - 8 - title.width,
290 ]), ...labelBlockers.flatMap((label) => [
291 label.x + label.width + 8, label.x - 8 - title.width,
292 ])].filter((value) => value >= left && value <= right)
293 .sort((a, b) => a - b)
294 .find(clear);
295 if (x !== undefined) title.x = x;
296}
298function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
299 const placedTitles = [];
300 const measured = new Map();
301 const ordered = rawBoundaries
302 .map((boundary, index) => ({ boundary, index }))
303 .sort((left, right) => {
304 const areaDelta = left.boundary.width * left.boundary.height
305 - right.boundary.width * right.boundary.height;
306 return areaDelta || left.index - right.index;
307 });
309 for (const entry of ordered) {
310 const { index } = entry;
311 const boundary = expandBoundaryForReadableTitle(entry.boundary, minimumFontSize);
312 const title = measureBoundaryTitle(boundary, minimumFontSize);
313 let guard = 0;
314 while (guard < rawBoundaries.length + components.size + 1) {
315 guard += 1;
316 const blockers = [
317 ...placedTitles,
318 ...components.values(),
319 ].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
320 if (!blockers.length) break;
321 title.y = blockers.reduce(
322 (min, blocker) => Math.min(min, blocker.y - layout.boundaryLabelRailGap - title.height),
323 Infinity,
324 );
325 }
326 slideTitleOffRoutes(boundary, title, placedTitles);
327 placedTitles.push(title);
328 measured.set(index, { boundary, title });
329 }
331 return rawBoundaries.map((_boundary, index) => {
332 const { boundary, title } = measured.get(index);
333 const bottom = boundary.y + boundary.height;
334 // Profile-less schema-v1 inputs keep their legacy boundary geometry. A
335 // quality profile opts into the stricter title-composition contract and
336 // may expand the frame to contain an adapted title rail.
337 const y = enforcesBoundaryTitleComposition
338 ? Math.min(boundary.y, title.y - layout.boundaryLabelFrameInset)
339 : boundary.y;
340 return {
341 ...boundary,
342 y,
343 height: bottom - y,
344 title,
345 };
346 });
347}
349// ---- Routing state ----------------------------------------------------------
350// Initialized before the boundary-title work below: connection label rects are
351// part of the derived canvas, so the title convergence must measure the same
352// width the diagram actually renders into (a title sized for a narrower canvas
353// would fall below the desktop-readability floor once labels grow it). Routing
354// reads components, connections and the member-derived boundary frames (so an
355// automatic route never borrows a frame border as its corridor), never the
356// title-expanded frames or the viewBox.
357const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
358const routerOptions = {
359 distinctAutomaticPorts: true,
360 preferReadableRoutes: true,
361 // A single outward stub below the scene can remove a crossing without
362 // widening the canvas or shrinking its text. Fixed canvases keep their bounds.
363 readabilityBottomMargin: arch.meta?.viewBox ? 0 : 24,
364 frames: rawBoundaries.map((boundary) => ({
365 ...boundary,
366 radius: boundary.kind === 'security-group' ? 8 : 12,
367 })),
368 labelRectFor: (conn, points, { routes, labels }) => (conn.label ? reservedLabelRect({
369 label: { relation: conn, label: conn.label, ...connectionLabelBoxAt(conn, labelPoint(conn, points)) },
370 points,
371 routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
372 labels,
373 components: [...components.values()],
374 }) : null),
375};
376const { pathFor, connectionSides, connectionEndpointSide } = createRouter(components, arch.connections, routerOptions);
377function hasAutomaticRouteGeometry(connection) {
378 return !Array.isArray(connection?.via)
379 && (!connection?.route || connection.route === 'auto')
380 && connection?.channelX === undefined
381 && connection?.channelY === undefined;
382}
383// The auto canvas has to cover these rects; an authored viewBox is never
384// resized to fit them — there the containment rule reports the clipping.
385let connectionLabels = connectionLabelRects();
386// Unlabelled outer corridors are geometry too. Fitting only nodes and labels
387// can clip a valid explicit via route while every browser overflow check passes.
388const connectionGeometry = [
389 ...connectionLabels,
390 ...asArray(arch.connections)
391 .filter((conn) => components.has(conn.from) && components.has(conn.to))
392 .flatMap((conn) => pathFor(conn).points.map(([x, y]) => ({ x, y, width: 0, height: 0 }))),
393];
395function resolveBoundaryTitles() {
396 if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
397 return {
398 boundaries: layoutBoundaryTitles(rawBoundaries, layout.boundaryLabelFontMinimum),
399 readabilityProblem: null,
400 };
401 }
403 const maximumIterations = 32;
404 let candidateBoundaries = rawBoundaries;
405 for (let iteration = 0; iteration < maximumIterations; iteration += 1) {
406 const budgetViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
407 const minimumFontSize = Math.max(
408 layout.boundaryLabelFontMinimum,
409 minimumReadableSourceTextPx(budgetViewBoxWidth) + 1e-6,
410 );
411 const nextBoundaries = layoutBoundaryTitles(rawBoundaries, minimumFontSize);
412 const finalViewBoxWidth = resolvedViewBoxWidth(nextBoundaries);
413 const finalMinimumFontSize = Math.max(
414 layout.boundaryLabelFontMinimum,
415 minimumReadableSourceTextPx(finalViewBoxWidth),
416 );
417 if (minimumFontSize >= finalMinimumFontSize) {
418 return { boundaries: nextBoundaries, readabilityProblem: null };
419 }
420 candidateBoundaries = nextBoundaries;
421 }
423 const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
424 return {
425 boundaries: candidateBoundaries,
426 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.`,
427 };
428}
430const resolvedBoundaryTitles = resolveBoundaryTitles();
431const boundaries = resolvedBoundaryTitles.boundaries;
432const compositionFrames = boundaries.map((boundary, index) => ({
433 ...boundary,
434 id: boundary.id || index,
435 kind: boundary.kind || 'boundary',
436 radius: boundary.kind === 'security-group' ? 8 : 12,
437}));
439function componentContext(component) {
440 const scopes = boundaries
441 .filter((boundary) => asArray(boundary.wraps).includes(component.id))
442 .sort((a, b) => (b.width * b.height) - (a.width * a.height))
443 .map((boundary) => boundary.label);
444 return scopes.length ? scopes.join(' › ') : i18nText(arch.meta.locale, 'node.context.architecture');
445}
447// ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
448// Connection labels are diagram content, so an auto canvas that stopped at the
449// component/boundary bbox would clip them; the label rects join the fit here
450// and in the title convergence above, which sizes fonts for this same width.
451const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries, connectionGeometry);
452const legendY = () => viewBox[1] - 16;
454// Fit titles and canvas from the original geometry first. Fallback labels must
455// fit inside that canvas, so moving a label cannot trigger title reflow or a
456// canvas/label feedback loop. Keep standard and every authored label control.
457if (arch.meta?.quality_profile === 'showcase') {
458 connectionLabels = placeAutomaticLabels({
459 keepFallbackNearRoute: true,
460 labels: connectionLabels,
461 routes: asArray(arch.connections).flatMap((conn, relationIndex) => (
462 components.has(conn.from) && components.has(conn.to)
463 ? [{ relationIndex, points: pathFor(conn).points }] : []
464 )),
465 components: [...components.values()],
466 titles: boundaries.map(boundary => boundary.title),
467 viewBox,
468 // Leave the resolved legend band available; moving a label must not hide
469 // an otherwise visible legend. Existing labels keep their placement.
470 placementBottom: architectureLegendEntries.length
471 ? legendY() - 32 - legendFootprint(architectureLegendEntries, { width: viewBox[0] - layout.margin * 2 }).extraHeight
472 : viewBox[1],
473 });
474 for (const rect of connectionLabels) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
475}
477// ---- Validation: mechanical correctness, never layout taste -----------------
478function componentDiagnosticBox(component) {
479 return {
480 ...componentBox(component),
481 x: component.x,
482 y: component.y,
483 ...(Array.isArray(component.pos) ? { pos: [...component.pos] } : {}),
484 };
485}
487function validateArchitecture() {
488 const problems = [];
489 const diagnostics = [];
490 if (resolvedBoundaryTitles.readabilityProblem) {
491 problems.push(resolvedBoundaryTitles.readabilityProblem);
492 }
493 const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership';
494 if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
495 if (grid) {
496 validateGridPlacement(arch, grid, problems);
497 } else {
498 for (const c of asArray(arch.components)) {
499 if (!Array.isArray(c.pos) || c.pos.length !== 2) {
500 problems.push(`Component "${c.id}" must include pos [x, y] when layout.mode is omitted (free placement).`);
501 }
502 }
503 }
505 for (const c of components.values()) {
506 if (!isFinitePoint(c.x, c.y, c.width, c.height)) {
507 const message = `Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`;
508 diagnostics.push({
509 code: 'architecture/non-finite-component-geometry', severity: 'error', message,
510 subject: { diagramType: 'architecture', nodeId: c.id },
511 evidence: { pos: c.pos ?? null, size: [c.width, c.height] },
512 supportedFixes: [`replace component "${c.id}" pos and size with finite [number, number] values`],
513 });
514 problems.push(message);
515 continue;
516 }
517 if (c.width <= 0 || c.height <= 0) {
518 const message = `Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`;
519 diagnostics.push({
520 code: 'architecture/invalid-component-size', severity: 'error', message,
521 subject: { diagramType: 'architecture', nodeId: c.id },
522 evidence: { width: c.width, height: c.height },
523 supportedFixes: [`set component "${c.id}" width and height to values greater than 0`],
524 });
525 problems.push(message);
526 continue;
527 }
528 if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) {
529 const message = `Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`;
530 const overflow = {
531 left: Math.max(0, -c.x),
532 top: Math.max(0, -c.y),
533 right: Math.max(0, c.x + c.width - viewBox[0]),
534 bottom: Math.max(0, c.y + c.height - viewBox[1]),
535 };
536 const widerThanCanvas = overflow.left > 0 && overflow.right > 0;
537 const tallerThanCanvas = overflow.top > 0 && overflow.bottom > 0;
538 const supportedFixes = [];
539 // Move by exactly the overflow: it lands the edge flush (x + (-x) is exact
540 // in IEEE 754), while a rounded-up move pushes the opposite edge past a
541 // tight canvas and a rounded-down one leaves the overflow in place. When
542 // the component is larger than the canvas, the move must shrink it too.
543 if (overflow.left > 0) {
544 const shrink = Math.max(0, c.width - viewBox[0]);
545 supportedFixes.push(shrink > 0
546 ? `move component "${c.id}" right by ${overflow.left}px and shrink its width by at least ${shrink}px`
547 : `move component "${c.id}" right by ${overflow.left}px`);
548 }
549 if (overflow.top > 0) {
550 const shrink = Math.max(0, c.height - viewBox[1]);
551 supportedFixes.push(shrink > 0
552 ? `move component "${c.id}" down by ${overflow.top}px and shrink its height by at least ${shrink}px`
553 : `move component "${c.id}" down by ${overflow.top}px`);
554 }
555 if ((overflow.right > 0 && !widerThanCanvas) || (overflow.bottom > 0 && !tallerThanCanvas)) {
556 const coveredWidth = overflow.right > 0 && !widerThanCanvas ? Math.ceil(c.x + c.width) : viewBox[0];
557 const coveredHeight = overflow.bottom > 0 && !tallerThanCanvas ? Math.ceil(c.y + c.height) : viewBox[1];
558 supportedFixes.push(`set meta.viewBox to at least [${Math.max(viewBox[0], coveredWidth)}, ${Math.max(viewBox[1], coveredHeight)}]`);
559 }
560 diagnostics.push({
561 code: 'layout/component-out-of-bounds', severity: 'error', message,
562 subject: { diagramType: 'architecture', nodeId: c.id },
563 evidence: { bounds: componentDiagnosticBox(c), viewBox: [...viewBox], overflow },
564 supportedFixes,
565 });
566 problems.push(message);
567 }
568 const estLabelW = textUnits(c.label) * 6.6;
569 if (estLabelW > c.width + 8) {
570 const message = `Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label or widen size.`;
571 diagnostics.push({
572 code: 'architecture/component-label-overflow', severity: 'error', message,
573 subject: { diagramType: 'architecture', nodeId: c.id },
574 evidence: { text: c.label, estimatedWidthPx: Math.round(estLabelW), availableWidthPx: c.width },
575 supportedFixes: [
576 `shorten component "${c.id}" label "${c.label}" while preserving its meaning`,
577 `or widen component "${c.id}" to at least ${Math.ceil(estLabelW - 8)}px`,
578 ],
579 });
580 problems.push(message);
581 }
582 const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
583 if (brandRailProblem) problems.push(brandRailProblem);
584 // sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
585 // handles the ordinary case, this rejects what it cannot rescue.
586 const availableTextW = availableNodeTextWidth(c.width);
587 for (const [field, value, minimum] of [
588 ['Sublabel', c.sublabel, componentTextFit.sublabelMinimum],
589 ['Tag', c.tag, componentTextFit.tagMinimum],
590 ]) {
591 if (!value) continue;
592 const minimumW = minimumNodeTextWidth(value, minimum);
593 if (minimumW > availableTextW) {
594 const fieldKey = field.toLowerCase();
595 const message = `${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but component "${c.id}" provides ${availableTextW}px — shorten the ${fieldKey} or widen size.`;
596 diagnostics.push({
597 code: `architecture/component-${fieldKey}-overflow`, severity: 'error', message,
598 subject: { diagramType: 'architecture', nodeId: c.id },
599 evidence: { text: value, minimumFontPx: minimum, requiredWidthPx: Math.ceil(minimumW), availableWidthPx: availableTextW },
600 supportedFixes: [
601 `shorten component "${c.id}" ${fieldKey} "${value}" while preserving its meaning`,
602 `or widen component "${c.id}" so the ${fieldKey} keeps at least ${Math.ceil(minimumW)}px of text width at the ${minimum}px legible minimum`,
603 ],
604 });
605 problems.push(message);
606 }
607 }
608 }
610 // Component overlap — the highest-traffic hand-placement failure mode.
611 const list = [...components.values()];
612 for (let i = 0; i < list.length; i += 1) {
613 for (let j = i + 1; j < list.length; j += 1) {
614 if (rectsOverlap(list[i], list[j], 8)) {
615 const separation = componentSeparationOptions(list[i], list[j], 8);
616 const message = `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)}`;
617 diagnostics.push({
618 code: 'layout/component-overlap', severity: 'error', message,
619 subject: { diagramType: 'architecture', nodeId: list[i].id },
620 evidence: { otherId: list[j].id, minimumGapPx: 8, boxes: [componentDiagnosticBox(list[i]), componentDiagnosticBox(list[j])] },
621 supportedFixes: [
622 `move component "${list[j].id}" pos to [${separation[0].pos[0]}, ${separation[0].pos[1]}] (${separation[0].relation})`,
623 `or move component "${list[j].id}" pos to [${separation[1].pos[0]}, ${separation[1].pos[1]}] (${separation[1].relation})`,
624 `or shrink "${list[i].id}" or "${list[j].id}" while keeping every label and sublabel readable`,
625 ],
626 });
627 problems.push(message);
628 }
629 }
630 }
632 // Boundaries: every wrapped id must exist; the computed box must stay in view.
633 for (const boundary of asArray(arch.boundaries)) {
634 for (const id of asArray(boundary.wraps)) {
635 if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`);
636 }
637 }
638 const viewBoxRect = { x: 0, y: 0, width: viewBox[0], height: viewBox[1] };
639 for (const boundary of boundaries) {
640 if (!enforcesBoundaryTitleComposition) continue;
641 if (boundary.title.minimumWidth > boundary.title.availableWidth) {
642 problems.push(
643 `Boundary label "${boundary.label}" needs ~${Math.ceil(boundary.title.minimumWidth)}px to fit at the `
644 + `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px — `
645 + 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
646 );
647 }
648 if (!rectContains(boundary, boundary.title)) {
649 problems.push(
650 `Boundary label "${boundary.label}" extends outside its final frame — shorten the label or increase boundary pad.`,
651 );
652 }
653 if (!rectContains(viewBoxRect, boundary.title)) {
654 problems.push(
655 `Boundary label "${boundary.label}" extends outside the viewBox — move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
656 );
657 }
658 for (const component of components.values()) {
659 if (!rectsOverlap(boundary.title, component)) continue;
660 problems.push(
661 `Boundary label "${boundary.label}" overlaps component "${component.id}" — move the component, increase boundary title space, or shorten the label.`,
662 );
663 }
664 }
665 for (let leftIndex = 0; leftIndex < boundaries.length; leftIndex += 1) {
666 const left = boundaries[leftIndex];
667 const leftMembers = new Set(asArray(left.wraps));
668 for (let rightIndex = leftIndex + 1; rightIndex < boundaries.length; rightIndex += 1) {
669 const right = boundaries[rightIndex];
670 if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
671 problems.push(
672 `Boundary labels "${left.label}" and "${right.label}" overlap — shorten a label or increase boundary title space.`,
673 );
674 }
675 // Ordinary architecture boundaries are sets, not an implied ownership
676 // tree: orthogonal scopes such as runtime and compliance may share some
677 // components while each contains others. The opt-in deployment profile
678 // does promise hierarchical region/private-scope membership, so only it
679 // receives the stricter membership-to-frame containment contract.
680 if (!requiresNestedBoundaryMembership) continue;
681 const rightMembers = new Set(asArray(right.wraps));
682 const shared = [...leftMembers].filter((id) => rightMembers.has(id));
683 const leftNested = [...leftMembers].every((id) => rightMembers.has(id));
684 const rightNested = [...rightMembers].every((id) => leftMembers.has(id));
685 if (shared.length && !leftNested && !rightNested) {
686 const leftOnly = [...leftMembers].filter((id) => !rightMembers.has(id));
687 const rightOnly = [...rightMembers].filter((id) => !leftMembers.has(id));
688 problems.push(
689 `Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
690 + `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
691 + `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
692 + `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) — `
693 + 'keep one boundary fully nested by removing outside members, or split the boundary.',
694 );
695 continue;
696 }
698 if (!rectsOverlap(left, right)) continue;
699 const leftContainsRight = rectContains(left, right);
700 const rightContainsLeft = rectContains(right, left);
701 if (!leftContainsRight && !rightContainsLeft) {
702 problems.push(
703 `Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap — `
704 + 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
705 );
706 continue;
707 }
709 if (!shared.length) {
710 problems.push(
711 `Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint — `
712 + 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
713 );
714 continue;
715 }
717 const containmentMatchesMembership = (leftNested && rightContainsLeft)
718 || (rightNested && leftContainsRight);
719 if (!containmentMatchesMembership) {
720 problems.push(
721 `Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership — `
722 + 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
723 );
724 }
725 }
726 }
727 for (const b of boundaries) {
728 if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
729 const overflow = {
730 left: Math.max(0, -b.x),
731 top: Math.max(0, -b.y),
732 right: Math.max(0, b.x + b.width - viewBox[0]),
733 bottom: Math.max(0, b.y + b.height - viewBox[1]),
734 };
735 const sides = Object.entries(overflow).filter(([, pixels]) => pixels > 0)
736 .map(([side, pixels]) => `${side} by ${Math.ceil(pixels)}px`).join(', ');
737 const supportedFixes = [];
738 if (overflow.left || overflow.top) {
739 supportedFixes.push(`move the wrapped components right by at least ${Math.ceil(overflow.left)}px and down by at least ${Math.ceil(overflow.top)}px, then revalidate connected routes and the opposite canvas sides; enlarging meta.viewBox cannot fix left/top overflow`);
740 }
741 if (overflow.right || overflow.bottom) {
742 supportedFixes.push(`increase meta.viewBox to at least [${Math.ceil(Math.max(viewBox[0], b.x + b.width))}, ${Math.ceil(Math.max(viewBox[1], b.y + b.height))}] for right/bottom overflow, or move the wrapped components inward; revalidate desktop readability`);
743 }
744 const message = `Boundary "${b.label}" extends outside the viewBox (${sides}) — preserve wraps membership and repair the measured canvas side.`;
745 diagnostics.push({
746 code: 'layout/boundary-out-of-bounds',
747 severity: 'error',
748 message,
749 subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps } },
750 evidence: {
751 bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
752 viewBox: [...viewBox],
753 overflow,
754 members: asArray(b.wraps).map((id) => components.get(id)).filter(Boolean).map(componentBox),
755 },
756 supportedFixes,
757 });
758 problems.push(message);
759 }
760 }
762 // A boundary frame is drawn as the padded box around its members, so a
763 // component that is not in `wraps` but sits wholly inside that box reads as
764 // a member. Report it instead of letting the picture claim false ownership.
765 // A box the frame border visibly cuts through does not claim membership,
766 // and earlier valid inputs depend on that, so only full enclosure fails.
767 // This new composition gate applies to showcase; standard keeps its existing
768 // acceptance contract while authors can opt into the stricter check.
769 if (arch.meta?.quality_profile === 'showcase') {
770 for (const b of boundaries) {
771 const members = new Set(asArray(b.wraps));
772 for (const component of components.values()) {
773 if (members.has(component.id) || !rectContains(b, component)) continue;
774 const message = `Component "${component.id}" is not wrapped by boundary "${b.label}" but sits inside its frame — `
775 + 'move it outside the frame, place the wrapped members in one compact axis-aligned cluster (no empty bay for a non-member), or add it to wraps only when it truly belongs there.';
776 problems.push(message);
777 diagnostics.push({
778 code: 'layout/boundary-encloses-non-member',
779 severity: 'error',
780 message,
781 subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps }, component: component.id },
782 evidence: {
783 bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
784 component: componentBox(component),
785 },
786 supportedFixes: [
787 `move "${component.id}" outside the boundary frame`,
788 'reposition the wrapped members into one compact axis-aligned cluster (no empty bay) that leaves non-members outside',
789 ],
790 });
791 }
792 }
793 }
795 const connectionList = asArray(arch.connections);
796 const knownComponentIds = [...components.keys()].sort();
797 for (const conn of connectionList) {
798 const connIndex = connectionList.indexOf(conn);
799 for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
800 if (components.has(conn[field])) continue;
801 const message = `Connection "${conn.label || conn[field]}" references unknown ${endpoint} "${conn[field]}".`;
802 const otherId = conn[field === 'from' ? 'to' : 'from'];
803 const neighbors = new Set();
804 for (const edge of connectionList) {
805 if (edge === conn) continue;
806 if (edge.from === otherId) neighbors.add(edge.to);
807 if (edge.to === otherId) neighbors.add(edge.from);
808 }
809 const candidates = knownComponentIds
810 .filter((id) => id !== otherId)
811 .sort((a, b) => (neighbors.has(b) - neighbors.has(a)) || a.localeCompare(b));
812 diagnostics.push({
813 code: 'architecture/unknown-endpoint', severity: 'error', message,
814 subject: {
815 diagramType: 'architecture',
816 connection: conn.id ?? null,
817 path: `/connections/${connIndex}/${field}`,
818 from: conn.from,
819 to: conn.to,
820 },
821 evidence: { endpoint, unknownNodeId: conn[field], availableNodeIds: knownComponentIds },
822 supportedFixes: candidates.slice(0, 3).map((id) => `set /connections/${connIndex}/${field} to verified node id "${id}"`),
823 });
824 problems.push(message);
825 problems.push(`Connection "${conn.label || conn[field]}" endpoint "${conn[field]}" does not name a declared component.`);
826 }
827 if (components.has(conn.from) && components.has(conn.to)) {
828 const routed = pathFor(conn);
829 const outsidePoints = routed.points.filter(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1]);
830 if (arch.meta?.quality_profile === 'showcase' && outsidePoints.length) {
831 const message = `Connection "${conn.id || `${conn.from}->${conn.to}`}" extends outside the viewBox — move the measured outside route points inward or enlarge an authored viewBox for right/bottom overflow.`;
832 diagnostics.push({
833 code: 'layout/route-out-of-bounds', severity: 'error', message,
834 subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
835 evidence: { viewBox: [...viewBox], outsidePoints, points: routed.points },
836 supportedFixes: ['move negative route coordinates inside the canvas; for right/bottom overflow, enlarge meta.viewBox or reroute inward; preserve endpoints, direction and labels, then revalidate'],
837 });
838 problems.push(message);
839 }
840 const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
841 const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
842 if (distance < 24 && conn.from === conn.to) {
843 // "Move the components apart" cannot be executed for a self-loop; the
844 // ports sit on one component and the sides decide how far apart.
845 const message = `Self-loop "${conn.id || conn.label || conn.from}" on component "${conn.from}" has its two ports only ${Math.round(distance)}px apart (minimum 24px) — remove fromSide/toSide so the renderer can choose the loop's sides, or set fromSide and toSide to different sides.`;
846 diagnostics.push({
847 code: 'layout/self-loop-ports', severity: 'error', message,
848 subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
849 evidence: { distancePx: Math.round(distance), minimumPx: 24, fromSide: connectionEndpointSide(conn, 'source'), toSide: connectionEndpointSide(conn, 'target'), points: routed.points },
850 supportedFixes: ['remove fromSide/toSide from the self-loop', 'set fromSide and toSide to different sides of the component'],
851 });
852 problems.push(message);
853 } else if (distance < 24) {
854 problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) — place its components farther apart.`);
855 }
856 }
857 }
859 problems.push(...cleanEndpointSideProblems({
860 relations: arch.connections,
861 endpointIds: new Set(components.keys()),
862 pathFor,
863 diagramType: 'architecture',
864 relationCollection: 'connections',
865 fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
866 toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
867 routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide with perpendicular via segments',
868 }));
869 problems.push(...cleanFlowProblems({
870 relations: arch.connections,
871 obstacles: components.values(),
872 pathFor,
873 diagramType: 'architecture',
874 relationCollection: 'connections',
875 obstacleKind: 'component',
876 routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
877 }));
878 problems.push(...cleanCrossingProblems({
879 relations: arch.connections,
880 endpointIds: new Set(components.keys()),
881 pathFor,
882 diagramType: 'architecture',
883 relationCollection: 'connections',
884 profile: arch.meta?.quality_profile,
885 // Automatic architecture routes render with an opaque crossover halo.
886 // That makes a proper X visually unambiguous while explicit authored
887 // crossings remain a blocking composition error.
888 crossingResolved: (left, right) => (
889 hasAutomaticRouteGeometry(left) && hasAutomaticRouteGeometry(right)
890 ),
891 routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
892 }));
893 problems.push(...cleanAmbiguousCorridorProblems({
894 relations: arch.connections,
895 endpointIds: new Set(components.keys()),
896 pathFor,
897 diagramType: 'architecture',
898 relationCollection: 'connections',
899 profile: arch.meta?.quality_profile,
900 includeSharedEndpoints: (left, right) => hasAutomaticRouteGeometry(left) && !left.labelAt
901 && hasAutomaticRouteGeometry(right) && !right.labelAt,
902 routeHint: 'adjust route/via or fromSide/toSide so distinct connections do not visually merge'
903 }));
904 if ((process.env.ARCHIFY_QUALITY_PROFILE || arch.meta?.quality_profile) === 'showcase') {
905 const collisions = collectArrowheadCollisions({
906 routedRelations: asArray(arch.connections)
907 .filter((conn) => components.has(conn.from) && components.has(conn.to) && hasAutomaticRouteGeometry(conn) && !conn.labelAt)
908 .map((relation) => ({ relation, points: pathFor(relation).points })),
909 });
910 for (const hit of collisions) {
911 const left = hit.left.relation;
912 const right = hit.right.relation;
913 const message = `[composition/arrowhead-collision] automatic connections "${left.id || left.from}" and "${right.id || right.from}" into "${left.to}" have arrowheads ${hit.distance}px apart (minimum ${hit.minimum}px) — enlarge or reposition the destination, or choose separate toSide ports.`;
914 problems.push(message);
915 diagnostics.push({
916 code: 'composition/arrowhead-collision', severity: 'error', message,
917 subject: { diagramType: 'architecture', collection: 'connections', id: left.id, from: left.from, to: left.to },
918 evidence: { otherId: right.id, distancePx: hit.distance, minimumPx: hit.minimum, endpoints: [hit.left.tip, hit.right.tip] },
919 supportedFixes: ['enlarge or reposition the destination', 'choose separate toSide ports'],
920 });
921 }
922 }
923 problems.push(...cleanBorderRunProblems({
924 relations: arch.connections,
925 endpointIds: new Set(components.keys()),
926 frames: compositionFrames,
927 pathFor,
928 diagramType: 'architecture',
929 relationCollection: 'connections',
930 profile: arch.meta?.quality_profile,
931 routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border'
932 }));
933 problems.push(...cleanRouteRhythmProblems({
934 relations: arch.connections,
935 endpointIds: new Set(components.keys()),
936 pathFor,
937 diagramType: 'architecture',
938 relationCollection: 'connections',
939 profile: arch.meta?.quality_profile,
940 routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
941 }));
942 problems.push(...cleanRouteDetourProblems({
943 relations: arch.connections,
944 obstacles: components.values(),
945 contentRects: [...components.values(), ...boundaries],
946 endpointIds: new Set(components.keys()),
947 pathFor,
948 fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
949 toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
950 diagramType: 'architecture',
951 relationCollection: 'connections',
952 profile: arch.meta?.quality_profile,
953 }));
954 // Connection labels must not land on top of components.
955 const labelRects = connectionLabels;
956 for (const rect of labelRects) {
957 const blockedComponents = [...components.values()].filter(c => rectsOverlap(rect, c, -2));
958 const labelPinned = ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => rect.relation[key] !== undefined);
959 const points = pathFor(rect.relation).points;
960 const shortHorizontalGap = points.length === 2 && Math.abs(points[0][1] - points[1][1]) < 0.0001
961 ? Math.abs(points[1][0] - points[0][0]) : null;
962 const requiredGap = Math.ceil(rect.width + 16);
963 if (arch.meta?.quality_profile === 'showcase' && !labelPinned && blockedComponents.length
964 && shortHorizontalGap != null && shortHorizontalGap < requiredGap) {
965 const message = `Label "${rect.label}" has only ${Math.round(shortHorizontalGap)}px between "${rect.relation.from}" and "${rect.relation.to}"; it needs at least ${requiredGap}px to stay beside its route — increase that clear gap or place the connected nodes on another readable row, preserving the label.`;
966 problems.push(message);
967 diagnostics.push({
968 code: 'composition/label-gap', severity: 'error', message,
969 subject: { diagramType: 'architecture', collection: 'connections', id: rect.relation.id, from: rect.relation.from, to: rect.relation.to },
970 evidence: { clearGapPx: shortHorizontalGap, minimumGapPx: requiredGap, labelWidthPx: rect.width, obstacles: blockedComponents.map(c => c.id) },
971 supportedFixes: [`increase the clear gap between the connected nodes to at least ${requiredGap}px`, 'reposition the connected nodes together while preserving the full relationship label'],
972 });
973 } else {
974 for (const c of blockedComponents) {
975 problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c, 'component', viewBox, components.values())}`);
976 }
977 }
978 if (enforcesBoundaryTitleComposition) {
979 for (const boundary of boundaries) {
980 if (!rectsOverlap(boundary.title, rect)) continue;
981 problems.push(
982 `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.`,
983 );
984 }
985 }
986 }
987 problems.push(...cleanLabelRouteClearanceProblems({
988 relations: arch.connections,
989 labels: labelRects,
990 endpointIds: new Set(components.keys()),
991 pathFor,
992 diagramType: 'architecture',
993 relationCollection: 'connections',
994 profile: arch.meta?.quality_profile,
995 }));
996 // See collectLabelCanvasOverflow in shared/geometry.mjs. An auto canvas now
997 // covers these rects, so this reports authored viewBoxes and the origin side,
998 // which growth cannot reach.
999 problems.push(...cleanLabelCanvasContainmentProblems({
1000 labels: labelRects,
1001 viewBox,
1002 diagramType: 'architecture',
1003 relationCollection: 'connections',
1004 profile: arch.meta?.quality_profile,
1005 }));
1007 if (problems.length) {
1008 throwDiagnosticProblems('Architecture layout validation failed', problems, {
1009 subject: { diagramType: 'architecture' },
1010 diagnostics,
1011 });
1012 }
1013}
1015function buildLayoutReport() {
1016 const labels = connectionLabels.map((rect) => ({
1017 text: rect.label,
1018 x: Math.round(rect.x),
1019 y: Math.round(rect.y),
1020 width: Math.round(rect.width),
1021 height: 14,
1022 labelAt: [Math.round(rect.lx), Math.round(rect.ly)],
1023 }));
1024 return {
1025 ok: true,
1026 diagram_type: 'architecture',
1027 layout: grid ? { mode: 'grid', ...grid } : { mode: 'free' },
1028 viewBox,
1029 components: [...components.values()].map(componentBox),
1030 boundaries: boundaries.map(boundaryBox),
1031 connections: asArray(arch.connections)
1032 .filter((conn) => components.has(conn.from) && components.has(conn.to))
1033 .map((conn) => {
1034 const routed = pathFor(conn);
1035 const labelAt = conn.label ? resolvedLabelPoints.get(conn) || labelPoint(conn, routed.points) : null;
1036 return connectionPath(conn, routed, labelAt);
1037 }),
1038 labels,
1039 };
1040}
1042// ---- Rendering ---------------------------------------------------------------
1043function renderBoundaryFrame(b, index) {
1044 const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
1045 const rx = b.kind === 'security-group' ? 8 : 12;
1046 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"/>`;
1047}
1049function renderBoundaryLabel(b, index) {
1050 const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
1051 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)}">
1052 <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"/>
1053 <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>
1054 </g>`;
1055}
1057function renderConnectionPath(conn, index) {
1058 const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
1059 const routed = pathFor(conn);
1060 const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
1061 const automaticRoute = hasAutomaticRouteGeometry(conn);
1062 const underlay = automaticRoute
1063 ? ` <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>\n`
1064 : '';
1065 const crossover = automaticRoute
1066 ? ` data-composition-crossover="halo"${conn.labelAt ? '' : ' data-composition-independent="true"'}` : '';
1067 const edge = ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}"${crossover}${authoredStraightRouteAttrs(conn, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1068 if (!automaticRoute) return edge;
1069 // The wrapper is presentation-only: viewer state remains on the one semantic
1070 // edge, while CSS can keep its preceding mask underlay at the same opacity.
1071 return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
1072}
1074function renderConnectionLabel(conn, index) {
1075 const box = connectionLabelBox(conn);
1076 if (!box) return '';
1077 return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
1078 <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3" class="c-mask"/>
1079 <text x="${box.lx}" y="${box.ly}" class="${edgeLabelAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
1080 </g>`;
1081}
1083function renderComponent(c) {
1084 const fill = componentFill[c.type] || 'c-external';
1085 const accent = componentText[c.type] || 't-muted';
1086 const cx = c.cx;
1087 const hasSub = c.sublabel != null && c.sublabel !== '';
1088 const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4;
1089 const sub = hasSub
1090 ? `\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>`
1091 : '';
1092 const tag = c.tag
1093 ? `\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>`
1094 : '';
1095 const brand = renderBrandMark(c, { x: c.x + c.width - 22, y: c.y + 6 });
1096 const labelFontSize = fittedNodeFontSize(c.label, brandLabelFitWidth(c, c.width), 11, 8);
1097 const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
1098 return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
1099 ${focusNodeTitle(c.label, passport)}
1100 <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="8" class="c-mask"/>
1101 <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="8" class="${fill}"${animateAttr(arch.meta, 'node', componentSteps.get(c.id))} stroke-width="1.5"/>
1102 ${renderSemanticSigil(c.type, { icon: c.icon, x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
1103 <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}
1104 </g>`;
1105}
1107function renderLegend() {
1108 const entries = architectureLegendEntries;
1109 const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
1110 pointsFor: (connection) => pathFor(connection).points,
1111 labelRectFor: connectionLabelBox,
1112 });
1113 let contentBottom = 0;
1114 for (const component of components.values()) {
1115 contentBottom = Math.max(contentBottom, component.y + component.height);
1116 }
1117 for (const boundary of boundaries) {
1118 contentBottom = Math.max(contentBottom, boundary.y + boundary.height);
1119 }
1120 return renderResolvedLegend({
1121 entries,
1122 locale: arch.meta.locale,
1123 layout: {
1124 x: layout.margin,
1125 baselineY: legendY(),
1126 width: viewBox[0] - layout.margin * 2,
1127 minTitleY: contentBottom + 8,
1128 obstacles: relationshipObstacles,
1129 unfit: arch.meta?.legend === undefined ? 'hide' : 'error',
1130 diagramType: 'architecture',
1131 },
1132 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"/>`,
1133 });
1134}
1136function renderSvg() {
1137 // An automatic architecture canvas is compiler-measured geometry. Let the
1138 // Reader spend the real desktop height budget on it, including when an
1139 // outer route makes the canvas taller than the ordinary wide-diagram
1140 // threshold. Authored viewBoxes keep their geometry and existing Reader width policy;
1141 // their declared height may use readable document scrolling without reflow.
1142 const readerFit = arch.meta?.viewBox
1143 ? ' data-diagram-type="architecture" data-reader-fit="authored-height"'
1144 : ' data-reader-fit="intrinsic-height"';
1145 // A complete repository architecture is allowed to use normal page scroll;
1146 // keep its common-desktop text at a comfortable reading size instead of
1147 // shrinking a semantically rich graph to the universal emergency floor.
1148 const readerMinimumText = arch.meta?.viewBox ? '' : ' data-reader-min-text="7.5"';
1149 const readerPrimaryText = arch.meta?.viewBox ? '' : ' data-reader-primary-text="14"';
1150 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)} ${NODE_CARD_ATTR}${readerFit}${readerMinimumText}${readerPrimaryText}>
1151${svgAccessibleText(arch.meta, 'architecture')}
1152${renderDefinitions()}
1153${nodeCardStyle()}
1155 <!-- Background Grid -->
1156 <rect width="100%" height="100%" fill="url(#grid)" />
1158 <!-- Boundaries (behind everything) -->
1159${boundaries.map(renderBoundaryFrame).join('\n\n')}
1161 <!-- Connection paths (before components for correct z-order) -->
1162${asArray(arch.connections).map(renderConnectionPath).join('\n')}
1164 <!-- Components -->
1165${[...components.values()].map(renderComponent).join('\n\n')}
1167 <!-- Connection labels -->
1168${asArray(arch.connections).map(renderConnectionLabel).join('\n')}
1170 <!-- Boundary labels (foreground masks keep routes out of titles) -->
1171${boundaries.map(renderBoundaryLabel).join('\n\n')}
1173 <!-- Legend -->
1174${renderLegend()}
1175 </svg>`;
1176}
1178if (layoutJsonMode) {
1179 try {
1180 validateArchitecture();
1181 } catch (error) {
1182 // A rejected layout is still useful repair evidence. Input/implementation
1183 // failures must retain their existing failure boundary, not partial geometry.
1184 if (!error.archifyDiagnostics?.length) throw error;
1185 console.log(JSON.stringify({
1186 ...buildLayoutReport(),
1187 ...rendererFailure(error),
1188 contract: 'archify-architecture-layout-v1',
1189 }, null, 2));
1190 process.exitCode = 1;
1191 }
1192 if (!process.exitCode) console.log(JSON.stringify(buildLayoutReport(), null, 2));
1193} else {
1194 validateArchitecture();
1195 writeDiagram({
1196 outPath,
1197 template,
1198 diagramType: 'architecture',
1199 meta: arch.meta,
1200 svg: renderSvg(),
1201 cards: arch.cards,
1202 sourceEvidence,
1203 });
1204}