Files
archify-vscode-ext/vendor/archify/renderers/architecture/render-architecture.mjs
T
oleg-lukasonokandClaude Opus 5.5 d56e784e97 Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources
(live, as you type) and opens rendered Archify HTML in a viewer tab.
Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime.
Adds validation diagnostics, JSON schema help, source-link navigation,
export saving, render-to-file and open-in-browser commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:40:55 +03:00

1002 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
import { rendererFailure, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
import { translateMessage as i18nText } from '../shared/i18n.mjs';
import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
import { createRouter } from './routing.mjs';
import { placeAutomaticLabels, reservedLabelRect } from './labels.mjs';
import { cleanRouteDetourProblems } from '../shared/route-quality.mjs';
import {
asArray,
isFinitePoint,
rectsOverlap,
cleanEndpointSideProblems,
cleanFlowProblems,
cleanCrossingProblems,
cleanAmbiguousCorridorProblems,
collectArrowheadCollisions,
cleanBorderRunProblems,
cleanRouteRhythmProblems,
cleanLabelRouteClearanceProblems,
cleanLabelCanvasContainmentProblems,
suggestLabelObstacleFix,
suggestComponentSeparation,
polylinePath,
routePointsValue,
authoredStraightRouteAttrs,
labelPoint,
componentFill,
componentText,
arrowClassMap,
edgeLabelAccent,
} from '../shared/geometry.mjs';
const componentTextFit = {
sublabelPreferred: 9,
sublabelMinimum: 6,
tagPreferred: 7,
tagMinimum: 6,
};
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const layoutJsonMode = process.argv.includes('--layout-json');
const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
const { diagram: arch, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
rendererDir: __dirname,
diagramType: 'architecture',
defaultExample: 'web-app.architecture.json',
argv: cliArgs,
});
const grid = gridLayout(arch);
const layout = {
defaultW: 120,
defaultH: 60,
margin: 40,
// Boundary padding — the 30/50 rule that was a hand-arithmetic footgun
// (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
boundaryPad: 30,
boundaryExtraBottom: 20,
boundaryLabelBaseline: 18,
boundaryLabelClearance: 4,
boundaryLabelFontPreferred: 9,
boundaryLabelFontMinimum: 6,
boundaryLabelMaskHeight: 16,
boundaryLabelRailGap: 2,
boundaryLabelFrameInset: 4,
legendH: 28,
};
const LEGEND_CATALOG = [
'frontend',
'backend',
'database',
'cloud',
'security',
'messagebus',
'external',
].map((kind) => ({ kind, label: i18nText(arch.meta.locale, `legend.architecture.${kind}`) }));
// ---- Measure components from free coordinates --------------------------------
function measureComponent(c) {
const [x, y] = resolveComponentPos(c, grid);
const [w, h] = Array.isArray(c.size) ? c.size : [layout.defaultW, layout.defaultH];
return { ...c, x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 };
}
const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)]));
const enforcesBoundaryTitleComposition = Boolean(arch.meta?.quality_profile);
const componentSteps = new Map();
for (const [index, conn] of asArray(arch.connections).entries()) {
if (!componentSteps.has(conn.from)) componentSteps.set(conn.from, index);
if (!componentSteps.has(conn.to)) componentSteps.set(conn.to, index + 1);
}
for (const [index, c] of asArray(arch.components).entries()) {
if (!componentSteps.has(c.id)) componentSteps.set(c.id, index);
}
// ---- Boundaries computed from the `wraps` id list ---------------------------
function boundaryRect(boundary) {
const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean);
if (!members.length) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const member of members) {
minX = Math.min(minX, member.x);
minY = Math.min(minY, member.y);
maxX = Math.max(maxX, member.x + member.width);
maxY = Math.max(maxY, member.y + member.height);
}
const pad = boundary.pad ?? layout.boundaryPad;
const topPad = Math.max(
pad,
layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
);
return {
...boundary,
x: minX - pad,
y: minY - topPad,
width: maxX - minX + pad * 2,
height: maxY - minY + topPad + layout.boundaryExtraBottom,
memberTop: minY,
};
}
function rectContains(outer, inner) {
const epsilon = 1e-9;
return outer.x <= inner.x + epsilon
&& outer.y <= inner.y + epsilon
&& outer.x + outer.width + epsilon >= inner.x + inner.width
&& outer.y + outer.height + epsilon >= inner.y + inner.height;
}
function boundaryLabelWidth(label, fontSize) {
return Math.max(30, textUnits(label) * fontSize * 0.6 + 10);
}
const architectureLegendEntries = resolveLegend(
arch.meta?.legend,
LEGEND_CATALOG,
new Set([...components.values()].map((component) => component.type)),
);
// One source for connection label geometry: the rect the containment rule
// measures is the rect the SVG mask draws, the auto canvas covers, the legend
// avoids, and the layout report publishes.
const resolvedLabelPoints = new Map();
function connectionLabelBox(conn) {
if (!conn.label) return null;
return connectionLabelBoxAt(conn, resolvedLabelPoints.get(conn) || labelPoint(conn, pathFor(conn).points));
}
function connectionLabelBoxAt(conn, [lx, ly]) {
const width = Math.max(30, textUnits(conn.label) * 4.8 + 10);
return { x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly };
}
function connectionLabelRects() {
const rects = [];
for (const [relationIndex, conn] of asArray(arch.connections).entries()) {
if (!components.has(conn.from) || !components.has(conn.to)) continue;
const box = connectionLabelBox(conn);
if (!box) continue;
rects.push({ relation: conn, relationIndex, label: conn.label, ...box });
}
return rects;
}
function autoViewBoxFor(candidateBoundaries, extraRects = []) {
let maxX = 0;
let maxY = 0;
for (const rects of [components.values(), candidateBoundaries, extraRects]) {
for (const rect of rects) {
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
}
let width = Math.ceil(maxX + layout.margin);
let footprint = legendFootprint(architectureLegendEntries, {
width: Math.max(1, width - layout.margin * 2),
});
if (footprint.minWidth > width - layout.margin * 2) {
width = Math.ceil(footprint.minWidth + layout.margin * 2);
footprint = legendFootprint(architectureLegendEntries, {
width: width - layout.margin * 2,
});
}
return [
width,
Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight),
];
}
function resolvedViewBoxWidth(candidateBoundaries) {
if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
return arch.meta.viewBox[0];
}
return autoViewBoxFor(candidateBoundaries, connectionGeometry)[0];
}
function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
if (!enforcesBoundaryTitleComposition) return boundary;
const requiredWidth = boundaryLabelWidth(boundary.label, minimumFontSize)
+ layout.boundaryLabelFrameInset * 2;
const extra = Math.max(0, requiredWidth - boundary.width);
if (!extra) return boundary;
return {
...boundary,
x: boundary.x - extra / 2,
width: boundary.width + extra,
};
}
function measureBoundaryTitle(boundary, minimumFontSize) {
const availableWidth = Math.max(0, boundary.width - layout.boundaryLabelFrameInset * 2);
const units = textUnits(boundary.label);
const fitted = units > 0
? (availableWidth - 10) / (units * 0.6)
: layout.boundaryLabelFontPreferred;
const preferredFontSize = Math.max(layout.boundaryLabelFontPreferred, minimumFontSize);
const fontSize = Math.max(
minimumFontSize,
Math.min(preferredFontSize, fitted),
);
const desiredWidth = boundaryLabelWidth(boundary.label, fontSize);
const height = Math.max(layout.boundaryLabelMaskHeight, Math.ceil(fontSize + 7));
return {
x: boundary.x + layout.boundaryLabelFrameInset,
y: boundary.memberTop
- layout.boundaryLabelClearance
- height,
width: Math.min(availableWidth, desiredWidth),
height,
fontSize,
minimumFontSize,
baselineOffset: fontSize + 4,
availableWidth,
minimumWidth: boundaryLabelWidth(boundary.label, minimumFontSize),
};
}
function horizontalOverlap(left, right) {
return left.x < right.x + right.width && left.x + left.width > right.x;
}
function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
const placedTitles = [];
const measured = new Map();
const ordered = rawBoundaries
.map((boundary, index) => ({ boundary, index }))
.sort((left, right) => {
const areaDelta = left.boundary.width * left.boundary.height
- right.boundary.width * right.boundary.height;
return areaDelta || left.index - right.index;
});
for (const entry of ordered) {
const { index } = entry;
const boundary = expandBoundaryForReadableTitle(entry.boundary, minimumFontSize);
const title = measureBoundaryTitle(boundary, minimumFontSize);
let guard = 0;
while (guard < rawBoundaries.length + components.size + 1) {
guard += 1;
const blockers = [
...placedTitles,
...components.values(),
].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
if (!blockers.length) break;
title.y = blockers.reduce(
(min, blocker) => Math.min(min, blocker.y - layout.boundaryLabelRailGap - title.height),
Infinity,
);
}
placedTitles.push(title);
measured.set(index, { boundary, title });
}
return rawBoundaries.map((_boundary, index) => {
const { boundary, title } = measured.get(index);
const bottom = boundary.y + boundary.height;
// Profile-less schema-v1 inputs keep their legacy boundary geometry. A
// quality profile opts into the stricter title-composition contract and
// may expand the frame to contain an adapted title rail.
const y = enforcesBoundaryTitleComposition
? Math.min(boundary.y, title.y - layout.boundaryLabelFrameInset)
: boundary.y;
return {
...boundary,
y,
height: bottom - y,
title,
};
});
}
// ---- Routing state ----------------------------------------------------------
// Initialized before the boundary-title work below: connection label rects are
// part of the derived canvas, so the title convergence must measure the same
// width the diagram actually renders into (a title sized for a narrower canvas
// would fall below the desktop-readability floor once labels grow it). Routing
// reads components, connections and the member-derived boundary frames (so an
// automatic route never borrows a frame border as its corridor), never the
// title-expanded frames or the viewBox.
const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
const { pathFor, connectionSides, connectionEndpointSide } = createRouter(components, arch.connections, {
distinctAutomaticPorts: true,
preferReadableRoutes: true,
frames: rawBoundaries.map((boundary) => ({
...boundary,
radius: boundary.kind === 'security-group' ? 8 : 12,
})),
labelRectFor: (conn, points, { routes, labels }) => (conn.label ? reservedLabelRect({
label: { relation: conn, label: conn.label, ...connectionLabelBoxAt(conn, labelPoint(conn, points)) },
points,
routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
labels,
components: [...components.values()],
}) : null),
});
function hasAutomaticRouteGeometry(connection) {
return !Array.isArray(connection?.via)
&& (!connection?.route || connection.route === 'auto')
&& connection?.channelX === undefined
&& connection?.channelY === undefined;
}
// The auto canvas has to cover these rects; an authored viewBox is never
// resized to fit them — there the containment rule reports the clipping.
let connectionLabels = connectionLabelRects();
// Unlabelled outer corridors are geometry too. Fitting only nodes and labels
// can clip a valid explicit via route while every browser overflow check passes.
const connectionGeometry = [
...connectionLabels,
...asArray(arch.connections)
.filter((conn) => components.has(conn.from) && components.has(conn.to))
.flatMap((conn) => pathFor(conn).points.map(([x, y]) => ({ x, y, width: 0, height: 0 }))),
];
function resolveBoundaryTitles() {
if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
return {
boundaries: layoutBoundaryTitles(rawBoundaries, layout.boundaryLabelFontMinimum),
readabilityProblem: null,
};
}
const maximumIterations = 32;
let candidateBoundaries = rawBoundaries;
for (let iteration = 0; iteration < maximumIterations; iteration += 1) {
const budgetViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
const minimumFontSize = Math.max(
layout.boundaryLabelFontMinimum,
minimumReadableSourceTextPx(budgetViewBoxWidth) + 1e-6,
);
const nextBoundaries = layoutBoundaryTitles(rawBoundaries, minimumFontSize);
const finalViewBoxWidth = resolvedViewBoxWidth(nextBoundaries);
const finalMinimumFontSize = Math.max(
layout.boundaryLabelFontMinimum,
minimumReadableSourceTextPx(finalViewBoxWidth),
);
if (minimumFontSize >= finalMinimumFontSize) {
return { boundaries: nextBoundaries, readabilityProblem: null };
}
candidateBoundaries = nextBoundaries;
}
const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
return {
boundaries: candidateBoundaries,
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.`,
};
}
const resolvedBoundaryTitles = resolveBoundaryTitles();
const boundaries = resolvedBoundaryTitles.boundaries;
const compositionFrames = boundaries.map((boundary, index) => ({
...boundary,
id: boundary.id || index,
kind: boundary.kind || 'boundary',
radius: boundary.kind === 'security-group' ? 8 : 12,
}));
function componentContext(component) {
const scopes = boundaries
.filter((boundary) => asArray(boundary.wraps).includes(component.id))
.sort((a, b) => (b.width * b.height) - (a.width * a.height))
.map((boundary) => boundary.label);
return scopes.length ? scopes.join(' › ') : i18nText(arch.meta.locale, 'node.context.architecture');
}
// ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
// Connection labels are diagram content, so an auto canvas that stopped at the
// component/boundary bbox would clip them; the label rects join the fit here
// and in the title convergence above, which sizes fonts for this same width.
const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries, connectionGeometry);
const legendY = () => viewBox[1] - 16;
// Fit titles and canvas from the original geometry first. Fallback labels must
// fit inside that canvas, so moving a label cannot trigger title reflow or a
// canvas/label feedback loop. Keep standard and every authored label control.
if (arch.meta?.quality_profile === 'showcase') {
connectionLabels = placeAutomaticLabels({
keepFallbackNearRoute: true,
labels: connectionLabels,
routes: asArray(arch.connections).flatMap((conn, relationIndex) => (
components.has(conn.from) && components.has(conn.to)
? [{ relationIndex, points: pathFor(conn).points }] : []
)),
components: [...components.values()],
titles: boundaries.map(boundary => boundary.title),
viewBox,
// Leave the resolved legend band available; moving a label must not hide
// an otherwise visible legend. Existing labels keep their placement.
placementBottom: architectureLegendEntries.length
? legendY() - 32 - legendFootprint(architectureLegendEntries, { width: viewBox[0] - layout.margin * 2 }).extraHeight
: viewBox[1],
});
for (const rect of connectionLabels) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
}
// ---- Validation: mechanical correctness, never layout taste -----------------
function validateArchitecture() {
const problems = [];
const diagnostics = [];
if (resolvedBoundaryTitles.readabilityProblem) {
problems.push(resolvedBoundaryTitles.readabilityProblem);
}
const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership';
if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
if (grid) {
validateGridPlacement(arch, grid, problems);
} else {
for (const c of asArray(arch.components)) {
if (!Array.isArray(c.pos) || c.pos.length !== 2) {
problems.push(`Component "${c.id}" must include pos [x, y] when layout.mode is omitted (free placement).`);
}
}
}
for (const c of components.values()) {
if (!isFinitePoint(c.x, c.y, c.width, c.height)) {
problems.push(`Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`);
continue;
}
if (c.width <= 0 || c.height <= 0) {
problems.push(`Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`);
continue;
}
if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) {
problems.push(`Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`);
}
const estLabelW = textUnits(c.label) * 6.6;
if (estLabelW > c.width + 8) {
problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label or widen size.`);
}
const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
if (brandRailProblem) problems.push(brandRailProblem);
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
// handles the ordinary case, this rejects what it cannot rescue.
const availableTextW = availableNodeTextWidth(c.width);
for (const [field, value, minimum] of [
['Sublabel', c.sublabel, componentTextFit.sublabelMinimum],
['Tag', c.tag, componentTextFit.tagMinimum],
]) {
if (!value) continue;
const minimumW = minimumNodeTextWidth(value, minimum);
if (minimumW > availableTextW) {
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.`);
}
}
}
// Component overlap — the highest-traffic hand-placement failure mode.
const list = [...components.values()];
for (let i = 0; i < list.length; i += 1) {
for (let j = i + 1; j < list.length; j += 1) {
if (rectsOverlap(list[i], list[j], 8)) {
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)}`);
}
}
}
// Boundaries: every wrapped id must exist; the computed box must stay in view.
for (const boundary of asArray(arch.boundaries)) {
for (const id of asArray(boundary.wraps)) {
if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`);
}
}
const viewBoxRect = { x: 0, y: 0, width: viewBox[0], height: viewBox[1] };
for (const boundary of boundaries) {
if (!enforcesBoundaryTitleComposition) continue;
if (boundary.title.minimumWidth > boundary.title.availableWidth) {
problems.push(
`Boundary label "${boundary.label}" needs ~${Math.ceil(boundary.title.minimumWidth)}px to fit at the `
+ `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px — `
+ 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
);
}
if (!rectContains(boundary, boundary.title)) {
problems.push(
`Boundary label "${boundary.label}" extends outside its final frame — shorten the label or increase boundary pad.`,
);
}
if (!rectContains(viewBoxRect, boundary.title)) {
problems.push(
`Boundary label "${boundary.label}" extends outside the viewBox — move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
);
}
for (const component of components.values()) {
if (!rectsOverlap(boundary.title, component)) continue;
problems.push(
`Boundary label "${boundary.label}" overlaps component "${component.id}" — move the component, increase boundary title space, or shorten the label.`,
);
}
}
for (let leftIndex = 0; leftIndex < boundaries.length; leftIndex += 1) {
const left = boundaries[leftIndex];
const leftMembers = new Set(asArray(left.wraps));
for (let rightIndex = leftIndex + 1; rightIndex < boundaries.length; rightIndex += 1) {
const right = boundaries[rightIndex];
if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
problems.push(
`Boundary labels "${left.label}" and "${right.label}" overlap — shorten a label or increase boundary title space.`,
);
}
// Ordinary architecture boundaries are sets, not an implied ownership
// tree: orthogonal scopes such as runtime and compliance may share some
// components while each contains others. The opt-in deployment profile
// does promise hierarchical region/private-scope membership, so only it
// receives the stricter membership-to-frame containment contract.
if (!requiresNestedBoundaryMembership) continue;
const rightMembers = new Set(asArray(right.wraps));
const shared = [...leftMembers].filter((id) => rightMembers.has(id));
const leftNested = [...leftMembers].every((id) => rightMembers.has(id));
const rightNested = [...rightMembers].every((id) => leftMembers.has(id));
if (shared.length && !leftNested && !rightNested) {
const leftOnly = [...leftMembers].filter((id) => !rightMembers.has(id));
const rightOnly = [...rightMembers].filter((id) => !leftMembers.has(id));
problems.push(
`Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
+ `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
+ `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
+ `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) — `
+ 'keep one boundary fully nested by removing outside members, or split the boundary.',
);
continue;
}
if (!rectsOverlap(left, right)) continue;
const leftContainsRight = rectContains(left, right);
const rightContainsLeft = rectContains(right, left);
if (!leftContainsRight && !rightContainsLeft) {
problems.push(
`Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap — `
+ 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
);
continue;
}
if (!shared.length) {
problems.push(
`Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint — `
+ 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
);
continue;
}
const containmentMatchesMembership = (leftNested && rightContainsLeft)
|| (rightNested && leftContainsRight);
if (!containmentMatchesMembership) {
problems.push(
`Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership — `
+ 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
);
}
}
}
for (const b of boundaries) {
if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
const overflow = {
left: Math.max(0, -b.x),
top: Math.max(0, -b.y),
right: Math.max(0, b.x + b.width - viewBox[0]),
bottom: Math.max(0, b.y + b.height - viewBox[1]),
};
const sides = Object.entries(overflow).filter(([, pixels]) => pixels > 0)
.map(([side, pixels]) => `${side} by ${Math.ceil(pixels)}px`).join(', ');
const supportedFixes = [];
if (overflow.left || overflow.top) {
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`);
}
if (overflow.right || overflow.bottom) {
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`);
}
const message = `Boundary "${b.label}" extends outside the viewBox (${sides}) — preserve wraps membership and repair the measured canvas side.`;
diagnostics.push({
code: 'layout/boundary-out-of-bounds',
severity: 'error',
message,
subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps } },
evidence: {
bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
viewBox: [...viewBox],
overflow,
members: asArray(b.wraps).map((id) => components.get(id)).filter(Boolean).map(componentBox),
},
supportedFixes,
});
problems.push(message);
}
}
for (const conn of asArray(arch.connections)) {
if (!components.has(conn.from)) problems.push(`Connection "${conn.label || conn.from}" references unknown source "${conn.from}".`);
if (!components.has(conn.to)) problems.push(`Connection "${conn.label || conn.to}" references unknown target "${conn.to}".`);
if (components.has(conn.from) && components.has(conn.to)) {
const routed = pathFor(conn);
const outsidePoints = routed.points.filter(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1]);
if (arch.meta?.quality_profile === 'showcase' && outsidePoints.length) {
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.`;
diagnostics.push({
code: 'layout/route-out-of-bounds', severity: 'error', message,
subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
evidence: { viewBox: [...viewBox], outsidePoints, points: routed.points },
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'],
});
problems.push(message);
}
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
if (distance < 24 && conn.from === conn.to) {
// "Move the components apart" cannot be executed for a self-loop; the
// ports sit on one component and the sides decide how far apart.
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.`;
diagnostics.push({
code: 'layout/self-loop-ports', severity: 'error', message,
subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
evidence: { distancePx: Math.round(distance), minimumPx: 24, fromSide: connectionEndpointSide(conn, 'source'), toSide: connectionEndpointSide(conn, 'target'), points: routed.points },
supportedFixes: ['remove fromSide/toSide from the self-loop', 'set fromSide and toSide to different sides of the component'],
});
problems.push(message);
} else 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.`);
}
}
}
problems.push(...cleanEndpointSideProblems({
relations: arch.connections,
endpointIds: new Set(components.keys()),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide with perpendicular via segments',
}));
problems.push(...cleanFlowProblems({
relations: arch.connections,
obstacles: components.values(),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
obstacleKind: 'component',
routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
}));
problems.push(...cleanCrossingProblems({
relations: arch.connections,
endpointIds: new Set(components.keys()),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
// Automatic architecture routes render with an opaque crossover halo.
// That makes a proper X visually unambiguous while explicit authored
// crossings remain a blocking composition error.
crossingResolved: (left, right) => (
hasAutomaticRouteGeometry(left) && hasAutomaticRouteGeometry(right)
),
routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
}));
problems.push(...cleanAmbiguousCorridorProblems({
relations: arch.connections,
endpointIds: new Set(components.keys()),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
includeSharedEndpoints: (left, right) => hasAutomaticRouteGeometry(left) && !left.labelAt
&& hasAutomaticRouteGeometry(right) && !right.labelAt,
routeHint: 'adjust route/via or fromSide/toSide so distinct connections do not visually merge'
}));
if ((process.env.ARCHIFY_QUALITY_PROFILE || arch.meta?.quality_profile) === 'showcase') {
const collisions = collectArrowheadCollisions({
routedRelations: asArray(arch.connections)
.filter((conn) => components.has(conn.from) && components.has(conn.to) && hasAutomaticRouteGeometry(conn) && !conn.labelAt)
.map((relation) => ({ relation, points: pathFor(relation).points })),
});
for (const hit of collisions) {
const left = hit.left.relation;
const right = hit.right.relation;
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.`;
problems.push(message);
diagnostics.push({
code: 'composition/arrowhead-collision', severity: 'error', message,
subject: { diagramType: 'architecture', collection: 'connections', id: left.id, from: left.from, to: left.to },
evidence: { otherId: right.id, distancePx: hit.distance, minimumPx: hit.minimum, endpoints: [hit.left.tip, hit.right.tip] },
supportedFixes: ['enlarge or reposition the destination', 'choose separate toSide ports'],
});
}
}
problems.push(...cleanBorderRunProblems({
relations: arch.connections,
endpointIds: new Set(components.keys()),
frames: compositionFrames,
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border'
}));
problems.push(...cleanRouteRhythmProblems({
relations: arch.connections,
endpointIds: new Set(components.keys()),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
}));
problems.push(...cleanRouteDetourProblems({
relations: arch.connections,
obstacles: components.values(),
contentRects: [...components.values(), ...boundaries],
endpointIds: new Set(components.keys()),
pathFor,
fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
}));
// Connection labels must not land on top of components.
const labelRects = connectionLabels;
for (const rect of labelRects) {
const blockedComponents = [...components.values()].filter(c => rectsOverlap(rect, c, -2));
const labelPinned = ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => rect.relation[key] !== undefined);
const points = pathFor(rect.relation).points;
const shortHorizontalGap = points.length === 2 && Math.abs(points[0][1] - points[1][1]) < 0.0001
? Math.abs(points[1][0] - points[0][0]) : null;
const requiredGap = Math.ceil(rect.width + 16);
if (arch.meta?.quality_profile === 'showcase' && !labelPinned && blockedComponents.length
&& shortHorizontalGap != null && shortHorizontalGap < requiredGap) {
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.`;
problems.push(message);
diagnostics.push({
code: 'composition/label-gap', severity: 'error', message,
subject: { diagramType: 'architecture', collection: 'connections', id: rect.relation.id, from: rect.relation.from, to: rect.relation.to },
evidence: { clearGapPx: shortHorizontalGap, minimumGapPx: requiredGap, labelWidthPx: rect.width, obstacles: blockedComponents.map(c => c.id) },
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'],
});
} else {
for (const c of blockedComponents) {
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())}`);
}
}
if (enforcesBoundaryTitleComposition) {
for (const boundary of boundaries) {
if (!rectsOverlap(boundary.title, rect)) continue;
problems.push(
`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.`,
);
}
}
}
problems.push(...cleanLabelRouteClearanceProblems({
relations: arch.connections,
labels: labelRects,
endpointIds: new Set(components.keys()),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
}));
// See collectLabelCanvasOverflow in shared/geometry.mjs. An auto canvas now
// covers these rects, so this reports authored viewBoxes and the origin side,
// which growth cannot reach.
problems.push(...cleanLabelCanvasContainmentProblems({
labels: labelRects,
viewBox,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
}));
if (problems.length) {
throwDiagnosticProblems('Architecture layout validation failed', problems, {
subject: { diagramType: 'architecture' },
diagnostics,
});
}
}
function buildLayoutReport() {
const labels = connectionLabels.map((rect) => ({
text: rect.label,
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: 14,
labelAt: [Math.round(rect.lx), Math.round(rect.ly)],
}));
return {
ok: true,
diagram_type: 'architecture',
layout: grid ? { mode: 'grid', ...grid } : { mode: 'free' },
viewBox,
components: [...components.values()].map(componentBox),
boundaries: boundaries.map(boundaryBox),
connections: asArray(arch.connections)
.filter((conn) => components.has(conn.from) && components.has(conn.to))
.map((conn) => {
const routed = pathFor(conn);
const labelAt = conn.label ? resolvedLabelPoints.get(conn) || labelPoint(conn, routed.points) : null;
return connectionPath(conn, routed, labelAt);
}),
labels,
};
}
// ---- Rendering ---------------------------------------------------------------
function renderBoundaryFrame(b, index) {
const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
const rx = b.kind === 'security-group' ? 8 : 12;
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"/>`;
}
function renderBoundaryLabel(b, index) {
const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
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)}">
<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"/>
<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>
</g>`;
}
function renderConnectionPath(conn, index) {
const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
const routed = pathFor(conn);
const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
const automaticRoute = hasAutomaticRouteGeometry(conn);
const underlay = automaticRoute
? ` <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`
: '';
const crossover = automaticRoute
? ` data-composition-crossover="halo"${conn.labelAt ? '' : ' data-composition-independent="true"'}` : '';
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})"/>`;
if (!automaticRoute) return edge;
// The wrapper is presentation-only: viewer state remains on the one semantic
// edge, while CSS can keep its preceding mask underlay at the same opacity.
return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
}
function renderConnectionLabel(conn, index) {
const box = connectionLabelBox(conn);
if (!box) return '';
return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3" class="c-mask"/>
<text x="${box.lx}" y="${box.ly}" class="${edgeLabelAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
</g>`;
}
function renderComponent(c) {
const fill = componentFill[c.type] || 'c-external';
const accent = componentText[c.type] || 't-muted';
const cx = c.cx;
const hasSub = c.sublabel != null && c.sublabel !== '';
const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4;
const sub = hasSub
? `\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>`
: '';
const tag = c.tag
? `\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>`
: '';
const brand = renderBrandMark(c, { x: c.x + c.width - 22, y: c.y + 6 });
const labelFontSize = fittedNodeFontSize(c.label, brandLabelFitWidth(c, c.width), 11, 8);
const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
${focusNodeTitle(c.label, passport)}
<rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="c-mask"/>
<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"/>
${renderSemanticSigil(c.type, { icon: c.icon, x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
<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}
</g>`;
}
function renderLegend() {
const entries = architectureLegendEntries;
const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
pointsFor: (connection) => pathFor(connection).points,
labelRectFor: connectionLabelBox,
});
let contentBottom = 0;
for (const component of components.values()) {
contentBottom = Math.max(contentBottom, component.y + component.height);
}
for (const boundary of boundaries) {
contentBottom = Math.max(contentBottom, boundary.y + boundary.height);
}
return renderResolvedLegend({
entries,
locale: arch.meta.locale,
layout: {
x: layout.margin,
baselineY: legendY(),
width: viewBox[0] - layout.margin * 2,
minTitleY: contentBottom + 8,
obstacles: relationshipObstacles,
unfit: arch.meta?.legend === undefined ? 'hide' : 'error',
diagramType: 'architecture',
},
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"/>`,
});
}
function renderSvg() {
// An automatic architecture canvas is compiler-measured geometry. Let the
// Reader spend the real desktop height budget on it, including when an
// outer route makes the canvas taller than the ordinary wide-diagram
// threshold. Authored viewBoxes keep their geometry and existing Reader width policy;
// their declared height may use readable document scrolling without reflow.
const readerFit = arch.meta?.viewBox
? ' data-diagram-type="architecture" data-reader-fit="authored-height"'
: ' data-reader-fit="intrinsic-height"';
// A complete repository architecture is allowed to use normal page scroll;
// keep its common-desktop text at a comfortable reading size instead of
// shrinking a semantically rich graph to the universal emergency floor.
const readerMinimumText = arch.meta?.viewBox ? '' : ' data-reader-min-text="7.5"';
const readerPrimaryText = arch.meta?.viewBox ? '' : ' data-reader-primary-text="14"';
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)}${readerFit}${readerMinimumText}${readerPrimaryText}>
${svgAccessibleText(arch.meta, 'architecture')}
${renderDefinitions()}
<!-- Background Grid -->
<rect width="100%" height="100%" fill="url(#grid)" />
<!-- Boundaries (behind everything) -->
${boundaries.map(renderBoundaryFrame).join('\n\n')}
<!-- Connection paths (before components for correct z-order) -->
${asArray(arch.connections).map(renderConnectionPath).join('\n')}
<!-- Components -->
${[...components.values()].map(renderComponent).join('\n\n')}
<!-- Connection labels -->
${asArray(arch.connections).map(renderConnectionLabel).join('\n')}
<!-- Boundary labels (foreground masks keep routes out of titles) -->
${boundaries.map(renderBoundaryLabel).join('\n\n')}
<!-- Legend -->
${renderLegend()}
</svg>`;
}
if (layoutJsonMode) {
try {
validateArchitecture();
} catch (error) {
// A rejected layout is still useful repair evidence. Input/implementation
// failures must retain their existing failure boundary, not partial geometry.
if (!error.archifyDiagnostics?.length) throw error;
console.log(JSON.stringify({
...buildLayoutReport(),
...rendererFailure(error),
contract: 'archify-architecture-layout-v1',
}, null, 2));
process.exitCode = 1;
}
if (!process.exitCode) console.log(JSON.stringify(buildLayoutReport(), null, 2));
} else {
validateArchitecture();
writeDiagram({
outPath,
template,
diagramType: 'architecture',
meta: arch.meta,
svg: renderSvg(),
cards: arch.cards,
sourceEvidence,
});
}