Files
pi-config/skills/archify/renderers/architecture/render-architecture.mjs
Sam Rolfe 8e68df0830 Add archify skill (v2.14), pi-mermaid ext (v0.3.0), project-diagramming-mermaid + project-diagramming-archify skills
- skills/archify: vendored tt-a1i/archify 2.14.0 (MIT), pure Node 18+, zero deps
- extensions/pi-mermaid: renders mermaid blocks as ASCII in TUI; deps beautiful-mermaid + mermaid
- skills/project-diagramming-*: orchestration skills, per-project docs/ convention
2026-09-06 10:50:29 +10:00

811 lines
33 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, loadDiagram, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
import { 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 { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
import {
asArray,
isFinitePoint,
rectsOverlap,
segmentIntersectsRect,
cleanEndpointSideProblems,
cleanFlowProblems,
cleanCrossingProblems,
cleanAmbiguousCorridorProblems,
cleanBorderRunProblems,
cleanRouteRhythmProblems,
cleanLabelRouteClearanceProblems,
suggestLabelObstacleFix,
suggestComponentSeparation,
anchor,
automaticPortSpread,
automaticPortRhythmBridge,
defaultFromSide,
defaultToSide,
chosenSide,
routeHonorsEndpointSides,
normalizeRoutePoints,
polylinePath,
routePointsValue,
roundedPath,
labelPoint,
componentFill,
componentText,
arrowClassMap,
variantAccent,
} 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 } = loadDiagram({
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,
legendH: 28,
};
const LEGEND_CATALOG = [
['frontend', 'Frontend'],
['backend', 'Backend'],
['database', 'Database'],
['cloud', 'Cloud'],
['security', 'Security'],
['messagebus', 'Message bus'],
['external', 'External'],
].map(([kind, label]) => ({ kind, label }));
// ---- 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 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;
const minX = Math.min(...members.map((m) => m.x));
const minY = Math.min(...members.map((m) => m.y));
const maxX = Math.max(...members.map((m) => m.x + m.width));
const maxY = Math.max(...members.map((m) => m.y + m.height));
const pad = boundary.pad ?? layout.boundaryPad;
return {
...boundary,
x: minX - pad,
y: minY - pad,
width: maxX - minX + pad * 2,
height: maxY - minY + pad + layout.boundaryExtraBottom,
};
}
const boundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
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(' › ') : 'Architecture component';
}
const architectureLegendEntries = resolveLegend(
arch.meta?.legend,
LEGEND_CATALOG,
new Set([...components.values()].map((component) => component.type)),
);
// ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
function autoViewBox() {
let maxX = 0;
let maxY = 0;
for (const c of components.values()) {
maxX = Math.max(maxX, c.x + c.width);
maxY = Math.max(maxY, c.y + c.height);
}
for (const b of boundaries) {
maxX = Math.max(maxX, b.x + b.width);
maxY = Math.max(maxY, b.y + b.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),
];
}
const viewBox = arch.meta?.viewBox || autoViewBox();
const legendY = () => viewBox[1] - 16;
// ---- Validation: mechanical correctness, never layout taste -----------------
function validateArchitecture() {
const problems = [];
if (arch.schema_version !== 1) problems.push('Architecture files must set "schema_version": 1.');
if (arch.diagram_type !== 'architecture') problems.push('Architecture files must set "diagram_type": "architecture".');
if (!arch.meta?.title) problems.push('Architecture files must include meta.title.');
if (!Array.isArray(arch.components) || arch.components.length < 1) {
problems.push('Architecture diagrams need at least one component.');
}
if (arch.connections !== undefined && !Array.isArray(arch.connections)) problems.push('Architecture "connections" must be an array.');
if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.');
if (arch.cards !== undefined && !Array.isArray(arch.cards)) problems.push('Architecture "cards" must be an array.');
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.`);
}
// 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}".`);
}
}
for (const b of boundaries) {
if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
problems.push(`Boundary "${b.label}" extends outside the viewBox — its members sit too close to the canvas edge; add margin or enlarge meta.viewBox.`);
}
}
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 [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) 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) => inferredAutoSide(conn, 'source'),
toSideFor: (conn) => inferredAutoSide(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,
endpointIds: new Set(components.keys()),
obstacles: components.values(),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
obstacleKind: 'component',
profile: arch.meta?.quality_profile,
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,
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,
routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge'
}));
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'
}));
// Connection labels must not land on top of components.
const labelRects = [];
for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
const [lx, ly] = labelPoint(conn, pathFor(conn).points);
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
}
for (const rect of labelRects) {
for (const c of components.values()) {
if (rectsOverlap(rect, c, -2)) {
problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`);
}
}
}
problems.push(...cleanLabelRouteClearanceProblems({
relations: arch.connections,
labels: labelRects,
endpointIds: new Set(components.keys()),
pathFor,
diagramType: 'architecture',
relationCollection: 'connections',
profile: arch.meta?.quality_profile,
}));
if (problems.length) {
throwDiagnosticProblems('Architecture layout validation failed', problems, {
subject: { diagramType: 'architecture' },
});
}
}
function buildLayoutReport() {
const labels = [];
for (const conn of asArray(arch.connections)) {
if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
const [lx, ly] = labelPoint(conn, pathFor(conn).points);
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
labels.push({
text: conn.label,
x: Math.round(lx - w / 2),
y: Math.round(ly - 10),
width: Math.round(w),
height: 14,
labelAt: [Math.round(lx), Math.round(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 ? labelPoint(conn, routed.points) : null;
return connectionPath(conn, routed, labelAt);
}),
labels,
};
}
// ---- Connection routing ------------------------------------------------------
function routeClearsComponents(conn, points, clearance = 2) {
const endpointIds = new Set([conn.from, conn.to]);
for (const component of components.values()) {
if (endpointIds.has(component.id)) continue;
for (let index = 0; index < points.length - 1; index += 1) {
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
return false;
}
}
}
return true;
}
function routeClearsEndpointComponents(points, from, to) {
const lastSegment = points.length - 2;
for (let index = 0; index <= lastSegment; index += 1) {
const segment = { start: points[index], end: points[index + 1] };
if (index > 0 && segmentIntersectsRect(segment, from)) return false;
if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
}
return true;
}
const OUTWARD_SIDE_VECTOR = {
left: [-1, 0],
right: [1, 0],
top: [0, -1],
bottom: [0, 1],
};
function outwardStub(point, side, distance = 24) {
const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
return [point[0] + dx * distance, point[1] + dy * distance];
}
function collinearBacktrack(a, b, c) {
const first = [b[0] - a[0], b[1] - a[1]];
const second = [c[0] - b[0], c[1] - b[1]];
const cross = first[0] * second[1] - first[1] * second[0];
const dot = first[0] * second[0] + first[1] * second[1];
return Math.abs(cross) <= 0.0001 && dot < -0.0001;
}
function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
const startStub = outwardStub(start, fromSide);
const endStub = outwardStub(end, toSide);
const rawCandidates = [];
const minimumBridge = 16;
const verticalSides = new Set(['top', 'bottom']);
const horizontalSides = new Set(['left', 'right']);
// Port spreading can leave parallel-side anchors only a few pixels apart.
// Route through a bounded outside channel so we keep both endpoint normals
// without introducing a tiny, noisy connector between the two stubs.
if (verticalSides.has(fromSide) && verticalSides.has(toSide)
&& Math.abs(start[0] - end[0]) < minimumBridge) {
for (const channelX of [
Math.max(start[0], end[0]) + minimumBridge,
Math.min(start[0], end[0]) - minimumBridge,
]) {
rawCandidates.push([
startStub,
[channelX, startStub[1]],
[channelX, endStub[1]],
endStub,
]);
}
}
if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
&& Math.abs(start[1] - end[1]) < minimumBridge) {
for (const channelY of [
Math.max(start[1], end[1]) + minimumBridge,
Math.min(start[1], end[1]) - minimumBridge,
]) {
rawCandidates.push([
startStub,
[startStub[0], channelY],
[endStub[0], channelY],
endStub,
]);
}
}
rawCandidates.push(
[startStub, [endStub[0], startStub[1]], endStub],
[startStub, [startStub[0], endStub[1]], endStub],
);
return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
.filter((points) => points.length >= 2)
.filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
.filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
.filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
.map((points) => points.slice(1, -1));
}
const AUTOMATIC_PORT_CORNER_GUTTER = 16;
const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16;
function portHasCornerClearance(rect, side, point) {
if (side === 'left' || side === 'right') {
const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2);
return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
}
if (side === 'top' || side === 'bottom') {
const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2);
return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
}
return false;
}
function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
const hasExplicitGeometry = (
conn.via
|| (conn.route && conn.route !== 'auto')
|| conn.channelX !== undefined
|| conn.channelY !== undefined
|| conn.labelAt
);
const horizontallyFacing = (
(fromSide === 'right' && toSide === 'left')
|| (fromSide === 'left' && toSide === 'right')
);
const verticallyFacing = (
(fromSide === 'bottom' && toSide === 'top')
|| (fromSide === 'top' && toSide === 'bottom')
);
if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
const fromSpread = Boolean(ports?.from);
const toSpread = Boolean(ports?.to);
if (fromSpread && toSpread) return { start, end };
const hasExplicitSides = (
(conn.fromSide && conn.fromSide !== 'auto')
|| (conn.toSide && conn.toSide !== 'auto')
);
if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
const alignmentDelta = horizontallyFacing
? Math.abs(start[1] - end[1])
: Math.abs(start[0] - end[0]);
if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end };
// Keep the shared endpoint's distinct spread slot and move only the
// relationship's unshared endpoint onto that axis. With no spread endpoint,
// retain the existing least-movement choice between the two facing sides.
// If both endpoints are shared, preserve the outside bridge so no competing
// port is silently collapsed.
const alignEndToStart = horizontallyFacing
? { start, end: [end[0], start[1]] }
: { start, end: [start[0], end[1]] };
const alignStartToEnd = horizontallyFacing
? { start: [start[0], end[1]], end }
: { start: [end[0], start[1]], end };
const candidates = fromSpread
? [alignEndToStart]
: toSpread
? [alignStartToEnd]
: [alignEndToStart, alignStartToEnd];
for (const candidate of candidates) {
const points = [candidate.start, candidate.end];
if (portHasCornerClearance(from, fromSide, candidate.start)
&& portHasCornerClearance(to, toSide, candidate.end)
&& routeHonorsEndpointSides(points, fromSide, toSide)
&& routeClearsEndpointComponents(points, from, to)
&& routeClearsComponents(conn, points)) {
return candidate;
}
}
return { start, end };
}
function routeVia(conn, from, to, start, end, fromSide, toSide) {
if (conn.via) return conn.via;
switch (conn.route || 'auto') {
case 'straight':
return [];
case 'orthogonal-h': {
const midX = (start[0] + end[0]) / 2;
return [[midX, start[1]], [midX, end[1]]];
}
case 'orthogonal-v': {
const midY = (start[1] + end[1]) / 2;
return [[start[0], midY], [end[0], midY]];
}
case 'auto':
default: {
// Direct line unless the anchors are clearly orthogonal-friendly.
const deltaX = Math.abs(start[0] - end[0]);
const deltaY = Math.abs(start[1] - end[1]);
if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return [];
const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
accept: (points) => (
routeClearsEndpointComponents(points, from, to)
&& routeClearsComponents(conn, points)
),
});
if (rhythmBridge) return rhythmBridge.slice(1, -1);
// Automatic port spreading can leave otherwise aligned endpoints only a
// few pixels apart. A midpoint route would split that tiny difference
// into two unreadable endpoint stubs, so take a bounded outside channel
// when both anchors sit on parallel component sides.
const minimumStub = 8;
const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
const outsideChannels = [
Math.max(start[0], end[0]) + minimumStub * 2,
Math.min(start[0], end[0]) - minimumStub * 2,
];
for (const channelX of outsideChannels) {
const candidate = [[channelX, start[1]], [channelX, end[1]]];
const points = [start, ...candidate, end];
if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
}
}
const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
const outsideChannels = [
Math.max(start[1], end[1]) + minimumStub * 2,
Math.min(start[1], end[1]) - minimumStub * 2,
];
for (const channelY of outsideChannels) {
const candidate = [[start[0], channelY], [end[0], channelY]];
const points = [start, ...candidate, end];
if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
}
}
const midX = (start[0] + end[0]) / 2;
const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
const midY = (start[1] + end[1]) / 2;
const verticalFirst = [[start[0], midY], [end[0], midY]];
const candidates = [horizontalFirst, verticalFirst];
const sideSafe = candidates.filter((candidate) => (
routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
));
const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
const nearParallelPorts = (
((fromSide === 'top' || fromSide === 'bottom')
&& (toSide === 'top' || toSide === 'bottom')
&& deltaX < minimumStub * 2)
|| ((fromSide === 'left' || fromSide === 'right')
&& (toSide === 'left' || toSide === 'right')
&& deltaY < minimumStub * 2)
);
const ordered = [
...(nearParallelPorts ? sideAware : sideSafe),
...(nearParallelPorts ? sideSafe : sideAware),
...candidates.filter((candidate) => !sideSafe.includes(candidate)),
];
for (const candidate of ordered) {
const points = [start, ...candidate, end];
if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate;
}
// Both bounded doglegs are blocked. Keep the best endpoint-safe route
// when one exists so the universal Clean Flow gate reports the actual
// obstacle; otherwise preserve the historical deterministic fallback
// and let the endpoint-direction gate explain the side mismatch.
return sideSafe[0] || sideAware[0] || horizontalFirst;
}
}
}
const pathCache = new Map();
const automaticPorts = automaticPortSpread(arch.connections, components);
function connectionSides(conn) {
const from = components.get(conn.from);
const to = components.get(conn.to);
return {
fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)),
toSide: chosenSide(conn.toSide, defaultToSide(from, to)),
};
}
function inferredAutoSide(conn, endpoint) {
const field = endpoint === 'source' ? 'fromSide' : 'toSide';
if (conn[field] && conn[field] !== 'auto') return conn[field];
if (conn.via || (conn.route && conn.route !== 'auto')) return null;
return connectionSides(conn)[field];
}
function pathFor(conn) {
if (pathCache.has(conn)) return pathCache.get(conn);
const from = components.get(conn.from);
const to = components.get(conn.to);
const ports = automaticPorts.get(conn);
const { fromSide, toSide } = connectionSides(conn);
const baseStart = ports?.from || anchor(from, fromSide);
const baseEnd = ports?.to || anchor(to, toSide);
const { start, end } = alignFacingPorts(
conn,
from,
to,
baseStart,
baseEnd,
fromSide,
toSide,
ports,
);
const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end];
const routed = { d: roundedPath(points, 8), points };
pathCache.set(conn, routed);
return routed;
}
// ---- Rendering ---------------------------------------------------------------
function renderBoundary(b, index) {
const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
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}" x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"/>
<text x="${b.x + 8}" y="${b.y + 18}" class="${labelCls}" font-size="9" font-weight="600">${esc(b.label)}</text>`;
}
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);
return ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
}
function renderConnectionLabel(conn, index) {
if (!conn.label) return '';
const [lx, ly] = labelPoint(conn, pathFor(conn).points);
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
<rect x="${lx - w / 2}" y="${ly - 10}" width="${w}" height="14" rx="3" class="c-mask"/>
<text x="${lx}" y="${ly}" class="${variantAccent(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 passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c) };
return ` <g ${focusNodeAttrs(c.id, c.label, passport)}>
${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, { x: c.x + 6, y: c.y + 6 })}
<text${hasSub ? ' data-detail-anchor' : ''} x="${cx}" y="${labelY}" class="t-primary" font-size="11" 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: (connection) => {
if (!connection.label) return null;
const [x, y] = labelPoint(connection, pathFor(connection).points);
const width = Math.max(30, textUnits(connection.label) * 4.8 + 10);
return { x: x - width / 2, y: y - 10, width, height: 14 };
},
});
const contentBottom = Math.max(
0,
...[...components.values()].map((component) => component.y + component.height),
...boundaries.map((boundary) => boundary.y + boundary.height),
);
return renderResolvedLegend({
entries,
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() {
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta, 'architecture diagram')}>
${svgAccessibleText(arch.meta, 'architecture diagram')}
${renderDefinitions()}
<!-- Background Grid -->
<rect width="100%" height="100%" fill="url(#grid)" />
<!-- Boundaries (behind everything) -->
${boundaries.map(renderBoundary).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')}
<!-- Legend -->
${renderLegend()}
</svg>`;
}
validateArchitecture();
if (layoutJsonMode) {
console.log(JSON.stringify(buildLayoutReport(), null, 2));
process.exit(0);
}
writeDiagram({
outPath,
template,
diagramType: 'architecture',
meta: arch.meta,
svg: renderSvg(),
cards: arch.cards,
sourceEvidence,
});