- 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
811 lines
33 KiB
JavaScript
811 lines
33 KiB
JavaScript
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,
|
||
});
|