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 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 ` ${esc(b.label)}`; } 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 ` `; } 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 ` ${esc(conn.label)} `; } 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 ${esc(c.sublabel)}` : ''; const tag = c.tag ? `\n ${esc(c.tag)}` : ''; const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c) }; return ` ${focusNodeTitle(c.label, passport)} ${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })} ${esc(c.label)}${sub}${tag} `; } 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) => ``, }); } function renderSvg() { return ` ${svgAccessibleText(arch.meta, 'architecture diagram')} ${renderDefinitions()} ${boundaries.map(renderBoundary).join('\n\n')} ${asArray(arch.connections).map(renderConnectionPath).join('\n')} ${[...components.values()].map(renderComponent).join('\n\n')} ${asArray(arch.connections).map(renderConnectionLabel).join('\n')} ${renderLegend()} `; } 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, });