@stacksjs/defaults 0.74.41 → 0.74.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-cms/SKILL.md +4 -4
- package/ai/skills/stacks-composables/SKILL.md +51 -2
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +16 -2
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-technical-diagrams/LICENSE +1 -1
- package/ai/skills/stacks-technical-diagrams/SKILL.md +257 -229
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +69 -0
- package/ai/skills/stacks-technical-diagrams/assets/JetBrainsMono-OFL.txt +93 -0
- package/ai/skills/stacks-technical-diagrams/assets/template.html +14588 -929
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +10 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +86 -0
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +654 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +1898 -89
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +829 -0
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +31 -0
- package/ai/skills/stacks-technical-diagrams/brand-marks/catalog.json +131 -0
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +1221 -0
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +18 -22
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +58 -52
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +61 -0
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +47 -0
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +30 -23
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +31 -0
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +31 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +49 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +57 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +64 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +22 -16
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +71 -0
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +62 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +16 -11
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +279 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +391 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +243 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +65 -0
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +120 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +45 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +780 -73
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +25 -3
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +240 -52
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +31 -7
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +227 -50
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +36 -6
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +270 -63
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +563 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/bun-runtime.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +186 -10
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +127 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/engineering-profiles.mjs +157 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +3 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +1195 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +595 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +217 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +340 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +238 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +58 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +49 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +163 -19
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +51 -5
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -17
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +24 -470
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +154 -11
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +61 -4
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +72 -0
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +40 -18
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +43 -18
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +41 -4
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +97 -1
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +551 -12
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +3 -4
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
|
|
4
|
-
import { animateAttr,
|
|
3
|
+
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
|
5
5
|
import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
|
|
6
|
+
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
7
|
+
import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
|
8
|
+
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
|
9
|
+
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
|
10
|
+
import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
|
|
11
|
+
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
6
12
|
import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
|
|
7
13
|
import {
|
|
8
14
|
asArray,
|
|
9
15
|
isFinitePoint,
|
|
10
16
|
rectsOverlap,
|
|
17
|
+
segmentIntersectsRect,
|
|
18
|
+
cleanEndpointSideProblems,
|
|
19
|
+
cleanFlowProblems,
|
|
20
|
+
cleanCrossingProblems,
|
|
21
|
+
cleanAmbiguousCorridorProblems,
|
|
22
|
+
cleanBorderRunProblems,
|
|
23
|
+
cleanRouteRhythmProblems,
|
|
24
|
+
cleanLabelRouteClearanceProblems,
|
|
11
25
|
suggestLabelObstacleFix,
|
|
12
26
|
suggestComponentSeparation,
|
|
13
27
|
anchor,
|
|
28
|
+
automaticPortSpread,
|
|
29
|
+
automaticPortRhythmBridge,
|
|
14
30
|
defaultFromSide,
|
|
15
31
|
defaultToSide,
|
|
16
32
|
chosenSide,
|
|
33
|
+
routeHonorsEndpointSides,
|
|
34
|
+
normalizeRoutePoints,
|
|
17
35
|
polylinePath,
|
|
36
|
+
routePointsValue,
|
|
18
37
|
roundedPath,
|
|
19
38
|
labelPoint,
|
|
20
39
|
componentFill,
|
|
@@ -23,10 +42,17 @@ import {
|
|
|
23
42
|
variantAccent,
|
|
24
43
|
} from '../shared/geometry.mjs';
|
|
25
44
|
|
|
45
|
+
const componentTextFit = {
|
|
46
|
+
sublabelPreferred: 9,
|
|
47
|
+
sublabelMinimum: 6,
|
|
48
|
+
tagPreferred: 7,
|
|
49
|
+
tagMinimum: 6,
|
|
50
|
+
};
|
|
51
|
+
|
|
26
52
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
53
|
const layoutJsonMode = process.argv.includes('--layout-json');
|
|
28
54
|
const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
|
|
29
|
-
const { diagram: arch, template, outPath } =
|
|
55
|
+
const { diagram: arch, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
|
30
56
|
rendererDir: __dirname,
|
|
31
57
|
diagramType: 'architecture',
|
|
32
58
|
defaultExample: 'web-app.architecture.json',
|
|
@@ -43,9 +69,26 @@ const layout = {
|
|
|
43
69
|
// (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
|
|
44
70
|
boundaryPad: 30,
|
|
45
71
|
boundaryExtraBottom: 20,
|
|
72
|
+
boundaryLabelBaseline: 18,
|
|
73
|
+
boundaryLabelClearance: 4,
|
|
74
|
+
boundaryLabelFontPreferred: 9,
|
|
75
|
+
boundaryLabelFontMinimum: 6,
|
|
76
|
+
boundaryLabelMaskHeight: 16,
|
|
77
|
+
boundaryLabelRailGap: 2,
|
|
78
|
+
boundaryLabelFrameInset: 4,
|
|
46
79
|
legendH: 28,
|
|
47
80
|
};
|
|
48
81
|
|
|
82
|
+
const LEGEND_CATALOG = [
|
|
83
|
+
'frontend',
|
|
84
|
+
'backend',
|
|
85
|
+
'database',
|
|
86
|
+
'cloud',
|
|
87
|
+
'security',
|
|
88
|
+
'messagebus',
|
|
89
|
+
'external',
|
|
90
|
+
].map((kind) => ({ kind, label: i18nText(arch.meta.locale, `legend.architecture.${kind}`) }));
|
|
91
|
+
|
|
49
92
|
// ---- Measure components from free coordinates --------------------------------
|
|
50
93
|
function measureComponent(c) {
|
|
51
94
|
const [x, y] = resolveComponentPos(c, grid);
|
|
@@ -54,6 +97,7 @@ function measureComponent(c) {
|
|
|
54
97
|
}
|
|
55
98
|
|
|
56
99
|
const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)]));
|
|
100
|
+
const enforcesBoundaryTitleComposition = Boolean(arch.meta?.quality_profile);
|
|
57
101
|
const componentSteps = new Map();
|
|
58
102
|
for (const [index, conn] of asArray(arch.connections).entries()) {
|
|
59
103
|
if (!componentSteps.has(conn.from)) componentSteps.set(conn.from, index);
|
|
@@ -72,50 +116,230 @@ function boundaryRect(boundary) {
|
|
|
72
116
|
const maxX = Math.max(...members.map((m) => m.x + m.width));
|
|
73
117
|
const maxY = Math.max(...members.map((m) => m.y + m.height));
|
|
74
118
|
const pad = boundary.pad ?? layout.boundaryPad;
|
|
119
|
+
const topPad = Math.max(
|
|
120
|
+
pad,
|
|
121
|
+
layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
|
|
122
|
+
);
|
|
75
123
|
return {
|
|
76
124
|
...boundary,
|
|
77
125
|
x: minX - pad,
|
|
78
|
-
y: minY -
|
|
126
|
+
y: minY - topPad,
|
|
79
127
|
width: maxX - minX + pad * 2,
|
|
80
|
-
height: maxY - minY +
|
|
128
|
+
height: maxY - minY + topPad + layout.boundaryExtraBottom,
|
|
129
|
+
memberTop: minY,
|
|
81
130
|
};
|
|
82
131
|
}
|
|
83
132
|
|
|
84
|
-
|
|
133
|
+
function rectContains(outer, inner) {
|
|
134
|
+
const epsilon = 1e-9;
|
|
135
|
+
return outer.x <= inner.x + epsilon
|
|
136
|
+
&& outer.y <= inner.y + epsilon
|
|
137
|
+
&& outer.x + outer.width + epsilon >= inner.x + inner.width
|
|
138
|
+
&& outer.y + outer.height + epsilon >= inner.y + inner.height;
|
|
139
|
+
}
|
|
85
140
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
141
|
+
function boundaryLabelWidth(label, fontSize) {
|
|
142
|
+
return Math.max(30, textUnits(label) * fontSize * 0.6 + 10);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const architectureLegendEntries = resolveLegend(
|
|
146
|
+
arch.meta?.legend,
|
|
147
|
+
LEGEND_CATALOG,
|
|
148
|
+
new Set([...components.values()].map((component) => component.type)),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
function autoViewBoxFor(candidateBoundaries) {
|
|
152
|
+
const maxX = Math.max(
|
|
153
|
+
0,
|
|
154
|
+
...[...components.values()].map((component) => component.x + component.width),
|
|
155
|
+
...candidateBoundaries.map((boundary) => boundary.x + boundary.width),
|
|
156
|
+
);
|
|
157
|
+
const maxY = Math.max(
|
|
158
|
+
0,
|
|
159
|
+
...[...components.values()].map((component) => component.y + component.height),
|
|
160
|
+
...candidateBoundaries.map((boundary) => boundary.y + boundary.height),
|
|
161
|
+
);
|
|
162
|
+
let width = Math.ceil(maxX + layout.margin);
|
|
163
|
+
let footprint = legendFootprint(architectureLegendEntries, {
|
|
164
|
+
width: Math.max(1, width - layout.margin * 2),
|
|
165
|
+
});
|
|
166
|
+
if (footprint.minWidth > width - layout.margin * 2) {
|
|
167
|
+
width = Math.ceil(footprint.minWidth + layout.margin * 2);
|
|
168
|
+
footprint = legendFootprint(architectureLegendEntries, {
|
|
169
|
+
width: width - layout.margin * 2,
|
|
170
|
+
});
|
|
97
171
|
}
|
|
98
172
|
return [
|
|
99
|
-
|
|
100
|
-
Math.ceil(maxY + layout.margin + layout.legendH),
|
|
173
|
+
width,
|
|
174
|
+
Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight),
|
|
101
175
|
];
|
|
102
176
|
}
|
|
103
177
|
|
|
104
|
-
|
|
178
|
+
function resolvedViewBoxWidth(candidateBoundaries) {
|
|
179
|
+
if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
|
|
180
|
+
return arch.meta.viewBox[0];
|
|
181
|
+
}
|
|
182
|
+
return autoViewBoxFor(candidateBoundaries)[0];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
|
|
186
|
+
if (!enforcesBoundaryTitleComposition) return boundary;
|
|
187
|
+
const requiredWidth = boundaryLabelWidth(boundary.label, minimumFontSize)
|
|
188
|
+
+ layout.boundaryLabelFrameInset * 2;
|
|
189
|
+
const extra = Math.max(0, requiredWidth - boundary.width);
|
|
190
|
+
if (!extra) return boundary;
|
|
191
|
+
return {
|
|
192
|
+
...boundary,
|
|
193
|
+
x: boundary.x - extra / 2,
|
|
194
|
+
width: boundary.width + extra,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function measureBoundaryTitle(boundary, minimumFontSize) {
|
|
199
|
+
const availableWidth = Math.max(0, boundary.width - layout.boundaryLabelFrameInset * 2);
|
|
200
|
+
const units = textUnits(boundary.label);
|
|
201
|
+
const fitted = units > 0
|
|
202
|
+
? (availableWidth - 10) / (units * 0.6)
|
|
203
|
+
: layout.boundaryLabelFontPreferred;
|
|
204
|
+
const preferredFontSize = Math.max(layout.boundaryLabelFontPreferred, minimumFontSize);
|
|
205
|
+
const fontSize = Math.max(
|
|
206
|
+
minimumFontSize,
|
|
207
|
+
Math.min(preferredFontSize, fitted),
|
|
208
|
+
);
|
|
209
|
+
const desiredWidth = boundaryLabelWidth(boundary.label, fontSize);
|
|
210
|
+
const height = Math.max(layout.boundaryLabelMaskHeight, Math.ceil(fontSize + 7));
|
|
211
|
+
return {
|
|
212
|
+
x: boundary.x + layout.boundaryLabelFrameInset,
|
|
213
|
+
y: boundary.memberTop
|
|
214
|
+
- layout.boundaryLabelClearance
|
|
215
|
+
- height,
|
|
216
|
+
width: Math.min(availableWidth, desiredWidth),
|
|
217
|
+
height,
|
|
218
|
+
fontSize,
|
|
219
|
+
minimumFontSize,
|
|
220
|
+
baselineOffset: fontSize + 4,
|
|
221
|
+
availableWidth,
|
|
222
|
+
minimumWidth: boundaryLabelWidth(boundary.label, minimumFontSize),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function horizontalOverlap(left, right) {
|
|
227
|
+
return left.x < right.x + right.width && left.x + left.width > right.x;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
|
|
231
|
+
const placedTitles = [];
|
|
232
|
+
const measured = new Map();
|
|
233
|
+
const ordered = rawBoundaries
|
|
234
|
+
.map((boundary, index) => ({ boundary, index }))
|
|
235
|
+
.sort((left, right) => {
|
|
236
|
+
const areaDelta = left.boundary.width * left.boundary.height
|
|
237
|
+
- right.boundary.width * right.boundary.height;
|
|
238
|
+
return areaDelta || left.index - right.index;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
for (const entry of ordered) {
|
|
242
|
+
const { index } = entry;
|
|
243
|
+
const boundary = expandBoundaryForReadableTitle(entry.boundary, minimumFontSize);
|
|
244
|
+
const title = measureBoundaryTitle(boundary, minimumFontSize);
|
|
245
|
+
let guard = 0;
|
|
246
|
+
while (guard < rawBoundaries.length + components.size + 1) {
|
|
247
|
+
guard += 1;
|
|
248
|
+
const blockers = [
|
|
249
|
+
...placedTitles,
|
|
250
|
+
...components.values(),
|
|
251
|
+
].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
|
|
252
|
+
if (!blockers.length) break;
|
|
253
|
+
title.y = Math.min(
|
|
254
|
+
...blockers.map((blocker) => blocker.y - layout.boundaryLabelRailGap - title.height),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
placedTitles.push(title);
|
|
258
|
+
measured.set(index, { boundary, title });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return rawBoundaries.map((_boundary, index) => {
|
|
262
|
+
const { boundary, title } = measured.get(index);
|
|
263
|
+
const bottom = boundary.y + boundary.height;
|
|
264
|
+
// Profile-less schema-v1 inputs keep their legacy boundary geometry. A
|
|
265
|
+
// quality profile opts into the stricter title-composition contract and
|
|
266
|
+
// may expand the frame to contain an adapted title rail.
|
|
267
|
+
const y = enforcesBoundaryTitleComposition
|
|
268
|
+
? Math.min(boundary.y, title.y - layout.boundaryLabelFrameInset)
|
|
269
|
+
: boundary.y;
|
|
270
|
+
return {
|
|
271
|
+
...boundary,
|
|
272
|
+
y,
|
|
273
|
+
height: bottom - y,
|
|
274
|
+
title,
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
|
|
280
|
+
function resolveBoundaryTitles() {
|
|
281
|
+
if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
boundaries: layoutBoundaryTitles(rawBoundaries, layout.boundaryLabelFontMinimum),
|
|
284
|
+
readabilityProblem: null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const maximumIterations = 32;
|
|
289
|
+
let candidateBoundaries = rawBoundaries;
|
|
290
|
+
for (let iteration = 0; iteration < maximumIterations; iteration += 1) {
|
|
291
|
+
const budgetViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
|
|
292
|
+
const minimumFontSize = Math.max(
|
|
293
|
+
layout.boundaryLabelFontMinimum,
|
|
294
|
+
minimumReadableSourceTextPx(budgetViewBoxWidth) + 1e-6,
|
|
295
|
+
);
|
|
296
|
+
const nextBoundaries = layoutBoundaryTitles(rawBoundaries, minimumFontSize);
|
|
297
|
+
const finalViewBoxWidth = resolvedViewBoxWidth(nextBoundaries);
|
|
298
|
+
const finalMinimumFontSize = Math.max(
|
|
299
|
+
layout.boundaryLabelFontMinimum,
|
|
300
|
+
minimumReadableSourceTextPx(finalViewBoxWidth),
|
|
301
|
+
);
|
|
302
|
+
if (minimumFontSize >= finalMinimumFontSize) {
|
|
303
|
+
return { boundaries: nextBoundaries, readabilityProblem: null };
|
|
304
|
+
}
|
|
305
|
+
candidateBoundaries = nextBoundaries;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
|
|
309
|
+
return {
|
|
310
|
+
boundaries: candidateBoundaries,
|
|
311
|
+
readabilityProblem: `[composition/desktop-readability] Boundary title layout did not converge after ${maximumIterations} iterations for the final ${finalViewBoxWidth}px viewBox - shorten boundary labels, provide a wider authored viewBox, or move wrapped components closer to the left edge.`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const resolvedBoundaryTitles = resolveBoundaryTitles();
|
|
316
|
+
const boundaries = resolvedBoundaryTitles.boundaries;
|
|
317
|
+
const compositionFrames = boundaries.map((boundary, index) => ({
|
|
318
|
+
...boundary,
|
|
319
|
+
id: boundary.id || index,
|
|
320
|
+
kind: boundary.kind || 'boundary',
|
|
321
|
+
radius: boundary.kind === 'security-group' ? 8 : 12,
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
function componentContext(component) {
|
|
325
|
+
const scopes = boundaries
|
|
326
|
+
.filter((boundary) => asArray(boundary.wraps).includes(component.id))
|
|
327
|
+
.sort((a, b) => (b.width * b.height) - (a.width * a.height))
|
|
328
|
+
.map((boundary) => boundary.label);
|
|
329
|
+
return scopes.length ? scopes.join(' › ') : i18nText(arch.meta.locale, 'node.context.architecture');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
|
|
333
|
+
const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries);
|
|
105
334
|
const legendY = () => viewBox[1] - 16;
|
|
106
335
|
|
|
107
336
|
// ---- Validation: mechanical correctness, never layout taste -----------------
|
|
108
337
|
function validateArchitecture() {
|
|
109
338
|
const problems = [];
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
if (!arch.meta?.title) problems.push('Architecture files must include meta.title.');
|
|
113
|
-
if (!Array.isArray(arch.components) || arch.components.length < 1) {
|
|
114
|
-
problems.push('Architecture diagrams need at least one component.');
|
|
339
|
+
if (resolvedBoundaryTitles.readabilityProblem) {
|
|
340
|
+
problems.push(resolvedBoundaryTitles.readabilityProblem);
|
|
115
341
|
}
|
|
116
|
-
|
|
117
|
-
if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.');
|
|
118
|
-
if (arch.cards !== undefined && !Array.isArray(arch.cards)) problems.push('Architecture "cards" must be an array.');
|
|
342
|
+
const requiresNestedBoundaryMembership = arch.meta?.engineering_profile === 'deployment-ownership';
|
|
119
343
|
if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
|
|
120
344
|
if (grid) {
|
|
121
345
|
validateGridPlacement(arch, grid, problems);
|
|
@@ -141,7 +365,22 @@ function validateArchitecture() {
|
|
|
141
365
|
}
|
|
142
366
|
const estLabelW = textUnits(c.label) * 6.6;
|
|
143
367
|
if (estLabelW > c.width + 8) {
|
|
144
|
-
problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) - shorten the label
|
|
368
|
+
problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) - shorten the label or widen size.`);
|
|
369
|
+
}
|
|
370
|
+
const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
|
|
371
|
+
if (brandRailProblem) problems.push(brandRailProblem);
|
|
372
|
+
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
|
|
373
|
+
// handles the ordinary case, this rejects what it cannot rescue.
|
|
374
|
+
const availableTextW = availableNodeTextWidth(c.width);
|
|
375
|
+
for (const [field, value, minimum] of [
|
|
376
|
+
['Sublabel', c.sublabel, componentTextFit.sublabelMinimum],
|
|
377
|
+
['Tag', c.tag, componentTextFit.tagMinimum],
|
|
378
|
+
]) {
|
|
379
|
+
if (!value) continue;
|
|
380
|
+
const minimumW = minimumNodeTextWidth(value, minimum);
|
|
381
|
+
if (minimumW > availableTextW) {
|
|
382
|
+
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.`);
|
|
383
|
+
}
|
|
145
384
|
}
|
|
146
385
|
}
|
|
147
386
|
|
|
@@ -161,6 +400,95 @@ function validateArchitecture() {
|
|
|
161
400
|
if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`);
|
|
162
401
|
}
|
|
163
402
|
}
|
|
403
|
+
const viewBoxRect = { x: 0, y: 0, width: viewBox[0], height: viewBox[1] };
|
|
404
|
+
for (const boundary of boundaries) {
|
|
405
|
+
if (!enforcesBoundaryTitleComposition) continue;
|
|
406
|
+
if (boundary.title.minimumWidth > boundary.title.availableWidth) {
|
|
407
|
+
problems.push(
|
|
408
|
+
`Boundary label "${boundary.label}" needs ~${Math.ceil(boundary.title.minimumWidth)}px to fit at the `
|
|
409
|
+
+ `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px - `
|
|
410
|
+
+ 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (!rectContains(boundary, boundary.title)) {
|
|
414
|
+
problems.push(
|
|
415
|
+
`Boundary label "${boundary.label}" extends outside its final frame - shorten the label or increase boundary pad.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (!rectContains(viewBoxRect, boundary.title)) {
|
|
419
|
+
problems.push(
|
|
420
|
+
`Boundary label "${boundary.label}" extends outside the viewBox - move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
for (const component of components.values()) {
|
|
424
|
+
if (!rectsOverlap(boundary.title, component)) continue;
|
|
425
|
+
problems.push(
|
|
426
|
+
`Boundary label "${boundary.label}" overlaps component "${component.id}" - move the component, increase boundary title space, or shorten the label.`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (let leftIndex = 0; leftIndex < boundaries.length; leftIndex += 1) {
|
|
431
|
+
const left = boundaries[leftIndex];
|
|
432
|
+
const leftMembers = new Set(asArray(left.wraps));
|
|
433
|
+
for (let rightIndex = leftIndex + 1; rightIndex < boundaries.length; rightIndex += 1) {
|
|
434
|
+
const right = boundaries[rightIndex];
|
|
435
|
+
if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
|
|
436
|
+
problems.push(
|
|
437
|
+
`Boundary labels "${left.label}" and "${right.label}" overlap - shorten a label or increase boundary title space.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
// Ordinary architecture boundaries are sets, not an implied ownership
|
|
441
|
+
// tree: orthogonal scopes such as runtime and compliance may share some
|
|
442
|
+
// components while each contains others. The opt-in deployment profile
|
|
443
|
+
// does promise hierarchical region/private-scope membership, so only it
|
|
444
|
+
// receives the stricter membership-to-frame containment contract.
|
|
445
|
+
if (!requiresNestedBoundaryMembership) continue;
|
|
446
|
+
const rightMembers = new Set(asArray(right.wraps));
|
|
447
|
+
const shared = [...leftMembers].filter((id) => rightMembers.has(id));
|
|
448
|
+
const leftNested = [...leftMembers].every((id) => rightMembers.has(id));
|
|
449
|
+
const rightNested = [...rightMembers].every((id) => leftMembers.has(id));
|
|
450
|
+
if (shared.length && !leftNested && !rightNested) {
|
|
451
|
+
const leftOnly = [...leftMembers].filter((id) => !rightMembers.has(id));
|
|
452
|
+
const rightOnly = [...rightMembers].filter((id) => !leftMembers.has(id));
|
|
453
|
+
problems.push(
|
|
454
|
+
`Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
|
|
455
|
+
+ `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
|
|
456
|
+
+ `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
|
|
457
|
+
+ `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) - `
|
|
458
|
+
+ 'keep one boundary fully nested by removing outside members, or split the boundary.',
|
|
459
|
+
);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!rectsOverlap(left, right)) continue;
|
|
464
|
+
const leftContainsRight = rectContains(left, right);
|
|
465
|
+
const rightContainsLeft = rectContains(right, left);
|
|
466
|
+
if (!leftContainsRight && !rightContainsLeft) {
|
|
467
|
+
problems.push(
|
|
468
|
+
`Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap - `
|
|
469
|
+
+ 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
|
|
470
|
+
);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!shared.length) {
|
|
475
|
+
problems.push(
|
|
476
|
+
`Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint - `
|
|
477
|
+
+ 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
|
|
478
|
+
);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const containmentMatchesMembership = (leftNested && rightContainsLeft)
|
|
483
|
+
|| (rightNested && leftContainsRight);
|
|
484
|
+
if (!containmentMatchesMembership) {
|
|
485
|
+
problems.push(
|
|
486
|
+
`Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership - `
|
|
487
|
+
+ 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
164
492
|
for (const b of boundaries) {
|
|
165
493
|
if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
|
|
166
494
|
problems.push(`Boundary "${b.label}" extends outside the viewBox - its members sit too close to the canvas edge; add margin or enlarge meta.viewBox.`);
|
|
@@ -178,13 +506,70 @@ function validateArchitecture() {
|
|
|
178
506
|
}
|
|
179
507
|
}
|
|
180
508
|
|
|
509
|
+
problems.push(...cleanEndpointSideProblems({
|
|
510
|
+
relations: arch.connections,
|
|
511
|
+
endpointIds: new Set(components.keys()),
|
|
512
|
+
pathFor,
|
|
513
|
+
diagramType: 'architecture',
|
|
514
|
+
relationCollection: 'connections',
|
|
515
|
+
fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
|
|
516
|
+
toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
|
|
517
|
+
routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide with perpendicular via segments',
|
|
518
|
+
}));
|
|
519
|
+
problems.push(...cleanFlowProblems({
|
|
520
|
+
relations: arch.connections,
|
|
521
|
+
obstacles: components.values(),
|
|
522
|
+
pathFor,
|
|
523
|
+
diagramType: 'architecture',
|
|
524
|
+
relationCollection: 'connections',
|
|
525
|
+
obstacleKind: 'component',
|
|
526
|
+
routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
|
|
527
|
+
}));
|
|
528
|
+
problems.push(...cleanCrossingProblems({
|
|
529
|
+
relations: arch.connections,
|
|
530
|
+
endpointIds: new Set(components.keys()),
|
|
531
|
+
pathFor,
|
|
532
|
+
diagramType: 'architecture',
|
|
533
|
+
relationCollection: 'connections',
|
|
534
|
+
profile: arch.meta?.quality_profile,
|
|
535
|
+
routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
|
|
536
|
+
}));
|
|
537
|
+
problems.push(...cleanAmbiguousCorridorProblems({
|
|
538
|
+
relations: arch.connections,
|
|
539
|
+
endpointIds: new Set(components.keys()),
|
|
540
|
+
pathFor,
|
|
541
|
+
diagramType: 'architecture',
|
|
542
|
+
relationCollection: 'connections',
|
|
543
|
+
profile: arch.meta?.quality_profile,
|
|
544
|
+
routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge'
|
|
545
|
+
}));
|
|
546
|
+
problems.push(...cleanBorderRunProblems({
|
|
547
|
+
relations: arch.connections,
|
|
548
|
+
endpointIds: new Set(components.keys()),
|
|
549
|
+
frames: compositionFrames,
|
|
550
|
+
pathFor,
|
|
551
|
+
diagramType: 'architecture',
|
|
552
|
+
relationCollection: 'connections',
|
|
553
|
+
profile: arch.meta?.quality_profile,
|
|
554
|
+
routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border'
|
|
555
|
+
}));
|
|
556
|
+
problems.push(...cleanRouteRhythmProblems({
|
|
557
|
+
relations: arch.connections,
|
|
558
|
+
endpointIds: new Set(components.keys()),
|
|
559
|
+
pathFor,
|
|
560
|
+
diagramType: 'architecture',
|
|
561
|
+
relationCollection: 'connections',
|
|
562
|
+
profile: arch.meta?.quality_profile,
|
|
563
|
+
routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
|
|
564
|
+
}));
|
|
565
|
+
|
|
181
566
|
// Connection labels must not land on top of components.
|
|
182
567
|
const labelRects = [];
|
|
183
|
-
for (const conn of asArray(arch.connections)) {
|
|
568
|
+
for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
|
|
184
569
|
if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
|
|
185
570
|
const [lx, ly] = labelPoint(conn, pathFor(conn).points);
|
|
186
571
|
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
|
|
187
|
-
labelRects.push({ label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
|
|
572
|
+
labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
|
|
188
573
|
}
|
|
189
574
|
for (const rect of labelRects) {
|
|
190
575
|
for (const c of components.values()) {
|
|
@@ -192,10 +577,29 @@ function validateArchitecture() {
|
|
|
192
577
|
problems.push(`Label "${rect.label}" overlaps component "${c.id}" - adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`);
|
|
193
578
|
}
|
|
194
579
|
}
|
|
580
|
+
if (enforcesBoundaryTitleComposition) {
|
|
581
|
+
for (const boundary of boundaries) {
|
|
582
|
+
if (!rectsOverlap(boundary.title, rect)) continue;
|
|
583
|
+
problems.push(
|
|
584
|
+
`Boundary label "${boundary.label}" overlaps connection label "${rect.label}" - move the boundary title rail by adjusting wrapped component positions, or move the connection label with labelAt/labelDx/labelDy/labelSegment.`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
195
588
|
}
|
|
589
|
+
problems.push(...cleanLabelRouteClearanceProblems({
|
|
590
|
+
relations: arch.connections,
|
|
591
|
+
labels: labelRects,
|
|
592
|
+
endpointIds: new Set(components.keys()),
|
|
593
|
+
pathFor,
|
|
594
|
+
diagramType: 'architecture',
|
|
595
|
+
relationCollection: 'connections',
|
|
596
|
+
profile: arch.meta?.quality_profile,
|
|
597
|
+
}));
|
|
196
598
|
|
|
197
599
|
if (problems.length) {
|
|
198
|
-
|
|
600
|
+
throwDiagnosticProblems('Architecture layout validation failed', problems, {
|
|
601
|
+
subject: { diagramType: 'architecture' },
|
|
602
|
+
});
|
|
199
603
|
}
|
|
200
604
|
}
|
|
201
605
|
|
|
@@ -233,7 +637,178 @@ function buildLayoutReport() {
|
|
|
233
637
|
}
|
|
234
638
|
|
|
235
639
|
// ---- Connection routing ------------------------------------------------------
|
|
236
|
-
function
|
|
640
|
+
function routeClearsComponents(conn, points, clearance = 2) {
|
|
641
|
+
const endpointIds = new Set([conn.from, conn.to]);
|
|
642
|
+
for (const component of components.values()) {
|
|
643
|
+
if (endpointIds.has(component.id)) continue;
|
|
644
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
645
|
+
if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function routeClearsEndpointComponents(points, from, to) {
|
|
654
|
+
const lastSegment = points.length - 2;
|
|
655
|
+
for (let index = 0; index <= lastSegment; index += 1) {
|
|
656
|
+
const segment = { start: points[index], end: points[index + 1] };
|
|
657
|
+
if (index > 0 && segmentIntersectsRect(segment, from)) return false;
|
|
658
|
+
if (index < lastSegment && segmentIntersectsRect(segment, to)) return false;
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const OUTWARD_SIDE_VECTOR = {
|
|
664
|
+
left: [-1, 0],
|
|
665
|
+
right: [1, 0],
|
|
666
|
+
top: [0, -1],
|
|
667
|
+
bottom: [0, 1],
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
function outwardStub(point, side, distance = 24) {
|
|
671
|
+
const [dx, dy] = OUTWARD_SIDE_VECTOR[side] || [0, 0];
|
|
672
|
+
return [point[0] + dx * distance, point[1] + dy * distance];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function collinearBacktrack(a, b, c) {
|
|
676
|
+
const first = [b[0] - a[0], b[1] - a[1]];
|
|
677
|
+
const second = [c[0] - b[0], c[1] - b[1]];
|
|
678
|
+
const cross = first[0] * second[1] - first[1] * second[0];
|
|
679
|
+
const dot = first[0] * second[0] + first[1] * second[1];
|
|
680
|
+
return Math.abs(cross) <= 0.0001 && dot < -0.0001;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function sideAwareBridgeCandidates(start, end, fromSide, toSide) {
|
|
684
|
+
const startStub = outwardStub(start, fromSide);
|
|
685
|
+
const endStub = outwardStub(end, toSide);
|
|
686
|
+
const rawCandidates = [];
|
|
687
|
+
const minimumBridge = 16;
|
|
688
|
+
const verticalSides = new Set(['top', 'bottom']);
|
|
689
|
+
const horizontalSides = new Set(['left', 'right']);
|
|
690
|
+
|
|
691
|
+
// Port spreading can leave parallel-side anchors only a few pixels apart.
|
|
692
|
+
// Route through a bounded outside channel so we keep both endpoint normals
|
|
693
|
+
// without introducing a tiny, noisy connector between the two stubs.
|
|
694
|
+
if (verticalSides.has(fromSide) && verticalSides.has(toSide)
|
|
695
|
+
&& Math.abs(start[0] - end[0]) < minimumBridge) {
|
|
696
|
+
for (const channelX of [
|
|
697
|
+
Math.max(start[0], end[0]) + minimumBridge,
|
|
698
|
+
Math.min(start[0], end[0]) - minimumBridge,
|
|
699
|
+
]) {
|
|
700
|
+
rawCandidates.push([
|
|
701
|
+
startStub,
|
|
702
|
+
[channelX, startStub[1]],
|
|
703
|
+
[channelX, endStub[1]],
|
|
704
|
+
endStub,
|
|
705
|
+
]);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (horizontalSides.has(fromSide) && horizontalSides.has(toSide)
|
|
709
|
+
&& Math.abs(start[1] - end[1]) < minimumBridge) {
|
|
710
|
+
for (const channelY of [
|
|
711
|
+
Math.max(start[1], end[1]) + minimumBridge,
|
|
712
|
+
Math.min(start[1], end[1]) - minimumBridge,
|
|
713
|
+
]) {
|
|
714
|
+
rawCandidates.push([
|
|
715
|
+
startStub,
|
|
716
|
+
[startStub[0], channelY],
|
|
717
|
+
[endStub[0], channelY],
|
|
718
|
+
endStub,
|
|
719
|
+
]);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
rawCandidates.push(
|
|
724
|
+
[startStub, [endStub[0], startStub[1]], endStub],
|
|
725
|
+
[startStub, [startStub[0], endStub[1]], endStub],
|
|
726
|
+
);
|
|
727
|
+
return rawCandidates.map((candidate) => normalizeRoutePoints([start, ...candidate, end]))
|
|
728
|
+
.filter((points) => points.length >= 2)
|
|
729
|
+
.filter((points) => !collinearBacktrack(points[0], points[1], points[2] || points[1]))
|
|
730
|
+
.filter((points) => !collinearBacktrack(points.at(-3) || points.at(-2), points.at(-2), points.at(-1)))
|
|
731
|
+
.filter((points) => routeHonorsEndpointSides(points, fromSide, toSide))
|
|
732
|
+
.map((points) => points.slice(1, -1));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const AUTOMATIC_PORT_CORNER_GUTTER = 16;
|
|
736
|
+
const AUTOMATIC_PORT_ALIGNMENT_DELTA = 16;
|
|
737
|
+
|
|
738
|
+
function portHasCornerClearance(rect, side, point) {
|
|
739
|
+
if (side === 'left' || side === 'right') {
|
|
740
|
+
const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.height / 2);
|
|
741
|
+
return point[1] >= rect.y + inset && point[1] <= rect.y + rect.height - inset;
|
|
742
|
+
}
|
|
743
|
+
if (side === 'top' || side === 'bottom') {
|
|
744
|
+
const inset = Math.min(AUTOMATIC_PORT_CORNER_GUTTER, rect.width / 2);
|
|
745
|
+
return point[0] >= rect.x + inset && point[0] <= rect.x + rect.width - inset;
|
|
746
|
+
}
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function alignFacingPorts(conn, from, to, start, end, fromSide, toSide, ports) {
|
|
751
|
+
const hasExplicitGeometry = (
|
|
752
|
+
conn.via
|
|
753
|
+
|| (conn.route && conn.route !== 'auto')
|
|
754
|
+
|| conn.channelX !== undefined
|
|
755
|
+
|| conn.channelY !== undefined
|
|
756
|
+
|| conn.labelAt
|
|
757
|
+
);
|
|
758
|
+
const horizontallyFacing = (
|
|
759
|
+
(fromSide === 'right' && toSide === 'left')
|
|
760
|
+
|| (fromSide === 'left' && toSide === 'right')
|
|
761
|
+
);
|
|
762
|
+
const verticallyFacing = (
|
|
763
|
+
(fromSide === 'bottom' && toSide === 'top')
|
|
764
|
+
|| (fromSide === 'top' && toSide === 'bottom')
|
|
765
|
+
);
|
|
766
|
+
if (hasExplicitGeometry || (!horizontallyFacing && !verticallyFacing)) return { start, end };
|
|
767
|
+
|
|
768
|
+
const fromSpread = Boolean(ports?.from);
|
|
769
|
+
const toSpread = Boolean(ports?.to);
|
|
770
|
+
if (fromSpread && toSpread) return { start, end };
|
|
771
|
+
const hasExplicitSides = (
|
|
772
|
+
(conn.fromSide && conn.fromSide !== 'auto')
|
|
773
|
+
|| (conn.toSide && conn.toSide !== 'auto')
|
|
774
|
+
);
|
|
775
|
+
if (!fromSpread && !toSpread && hasExplicitSides) return { start, end };
|
|
776
|
+
|
|
777
|
+
const alignmentDelta = horizontallyFacing
|
|
778
|
+
? Math.abs(start[1] - end[1])
|
|
779
|
+
: Math.abs(start[0] - end[0]);
|
|
780
|
+
if (alignmentDelta >= AUTOMATIC_PORT_ALIGNMENT_DELTA) return { start, end };
|
|
781
|
+
|
|
782
|
+
// Keep the shared endpoint's distinct spread slot and move only the
|
|
783
|
+
// relationship's unshared endpoint onto that axis. With no spread endpoint,
|
|
784
|
+
// retain the existing least-movement choice between the two facing sides.
|
|
785
|
+
// If both endpoints are shared, preserve the outside bridge so no competing
|
|
786
|
+
// port is silently collapsed.
|
|
787
|
+
const alignEndToStart = horizontallyFacing
|
|
788
|
+
? { start, end: [end[0], start[1]] }
|
|
789
|
+
: { start, end: [start[0], end[1]] };
|
|
790
|
+
const alignStartToEnd = horizontallyFacing
|
|
791
|
+
? { start: [start[0], end[1]], end }
|
|
792
|
+
: { start: [end[0], start[1]], end };
|
|
793
|
+
const candidates = fromSpread
|
|
794
|
+
? [alignEndToStart]
|
|
795
|
+
: toSpread
|
|
796
|
+
? [alignStartToEnd]
|
|
797
|
+
: [alignEndToStart, alignStartToEnd];
|
|
798
|
+
for (const candidate of candidates) {
|
|
799
|
+
const points = [candidate.start, candidate.end];
|
|
800
|
+
if (portHasCornerClearance(from, fromSide, candidate.start)
|
|
801
|
+
&& portHasCornerClearance(to, toSide, candidate.end)
|
|
802
|
+
&& routeHonorsEndpointSides(points, fromSide, toSide)
|
|
803
|
+
&& routeClearsEndpointComponents(points, from, to)
|
|
804
|
+
&& routeClearsComponents(conn, points)) {
|
|
805
|
+
return candidate;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return { start, end };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function routeVia(conn, from, to, start, end, fromSide, toSide) {
|
|
237
812
|
if (conn.via) return conn.via;
|
|
238
813
|
switch (conn.route || 'auto') {
|
|
239
814
|
case 'straight':
|
|
@@ -249,48 +824,158 @@ function routeVia(conn, from, to, start, end) {
|
|
|
249
824
|
case 'auto':
|
|
250
825
|
default: {
|
|
251
826
|
// Direct line unless the anchors are clearly orthogonal-friendly.
|
|
252
|
-
|
|
827
|
+
const deltaX = Math.abs(start[0] - end[0]);
|
|
828
|
+
const deltaY = Math.abs(start[1] - end[1]);
|
|
829
|
+
if ((deltaX < 4 || deltaY < 4) && routeHonorsEndpointSides([start, end], fromSide, toSide)) return [];
|
|
830
|
+
|
|
831
|
+
const rhythmBridge = automaticPortRhythmBridge(start, end, fromSide, toSide, {
|
|
832
|
+
accept: (points) => (
|
|
833
|
+
routeClearsEndpointComponents(points, from, to)
|
|
834
|
+
&& routeClearsComponents(conn, points)
|
|
835
|
+
),
|
|
836
|
+
});
|
|
837
|
+
if (rhythmBridge) return rhythmBridge.slice(1, -1);
|
|
838
|
+
|
|
839
|
+
// Automatic port spreading can leave otherwise aligned endpoints only a
|
|
840
|
+
// few pixels apart. A midpoint route would split that tiny difference
|
|
841
|
+
// into two unreadable endpoint stubs, so take a bounded outside channel
|
|
842
|
+
// when both anchors sit on parallel component sides.
|
|
843
|
+
const minimumStub = 8;
|
|
844
|
+
const fromVerticalSide = start[1] === from.y || start[1] === from.y + from.height;
|
|
845
|
+
const toVerticalSide = end[1] === to.y || end[1] === to.y + to.height;
|
|
846
|
+
if (fromVerticalSide && toVerticalSide && deltaX < minimumStub * 2) {
|
|
847
|
+
const outsideChannels = [
|
|
848
|
+
Math.max(start[0], end[0]) + minimumStub * 2,
|
|
849
|
+
Math.min(start[0], end[0]) - minimumStub * 2,
|
|
850
|
+
];
|
|
851
|
+
for (const channelX of outsideChannels) {
|
|
852
|
+
const candidate = [[channelX, start[1]], [channelX, end[1]]];
|
|
853
|
+
const points = [start, ...candidate, end];
|
|
854
|
+
if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const fromHorizontalSide = start[0] === from.x || start[0] === from.x + from.width;
|
|
859
|
+
const toHorizontalSide = end[0] === to.x || end[0] === to.x + to.width;
|
|
860
|
+
if (fromHorizontalSide && toHorizontalSide && deltaY < minimumStub * 2) {
|
|
861
|
+
const outsideChannels = [
|
|
862
|
+
Math.max(start[1], end[1]) + minimumStub * 2,
|
|
863
|
+
Math.min(start[1], end[1]) - minimumStub * 2,
|
|
864
|
+
];
|
|
865
|
+
for (const channelY of outsideChannels) {
|
|
866
|
+
const candidate = [[start[0], channelY], [end[0], channelY]];
|
|
867
|
+
const points = [start, ...candidate, end];
|
|
868
|
+
if (routeHonorsEndpointSides(points, fromSide, toSide) && routeClearsComponents(conn, points)) return candidate;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
253
872
|
const midX = (start[0] + end[0]) / 2;
|
|
254
|
-
|
|
873
|
+
const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
|
|
874
|
+
const midY = (start[1] + end[1]) / 2;
|
|
875
|
+
const verticalFirst = [[start[0], midY], [end[0], midY]];
|
|
876
|
+
const candidates = [horizontalFirst, verticalFirst];
|
|
877
|
+
const sideSafe = candidates.filter((candidate) => (
|
|
878
|
+
routeHonorsEndpointSides([start, ...candidate, end], fromSide, toSide)
|
|
879
|
+
));
|
|
880
|
+
const sideAware = sideAwareBridgeCandidates(start, end, fromSide, toSide);
|
|
881
|
+
const nearParallelPorts = (
|
|
882
|
+
((fromSide === 'top' || fromSide === 'bottom')
|
|
883
|
+
&& (toSide === 'top' || toSide === 'bottom')
|
|
884
|
+
&& deltaX < minimumStub * 2)
|
|
885
|
+
|| ((fromSide === 'left' || fromSide === 'right')
|
|
886
|
+
&& (toSide === 'left' || toSide === 'right')
|
|
887
|
+
&& deltaY < minimumStub * 2)
|
|
888
|
+
);
|
|
889
|
+
const ordered = [
|
|
890
|
+
...(nearParallelPorts ? sideAware : sideSafe),
|
|
891
|
+
...(nearParallelPorts ? sideSafe : sideAware),
|
|
892
|
+
...candidates.filter((candidate) => !sideSafe.includes(candidate)),
|
|
893
|
+
];
|
|
894
|
+
for (const candidate of ordered) {
|
|
895
|
+
const points = [start, ...candidate, end];
|
|
896
|
+
if (routeClearsEndpointComponents(points, from, to) && routeClearsComponents(conn, points)) return candidate;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Both bounded doglegs are blocked. Keep the best endpoint-safe route
|
|
900
|
+
// when one exists so the universal Clean Flow gate reports the actual
|
|
901
|
+
// obstacle; otherwise preserve the historical deterministic fallback
|
|
902
|
+
// and let the endpoint-direction gate explain the side mismatch.
|
|
903
|
+
return sideSafe[0] || sideAware[0] || horizontalFirst;
|
|
255
904
|
}
|
|
256
905
|
}
|
|
257
906
|
}
|
|
258
907
|
|
|
259
908
|
const pathCache = new Map();
|
|
909
|
+
const automaticPorts = automaticPortSpread(arch.connections, components);
|
|
910
|
+
function connectionSides(conn) {
|
|
911
|
+
const from = components.get(conn.from);
|
|
912
|
+
const to = components.get(conn.to);
|
|
913
|
+
return {
|
|
914
|
+
fromSide: chosenSide(conn.fromSide, defaultFromSide(from, to)),
|
|
915
|
+
toSide: chosenSide(conn.toSide, defaultToSide(from, to)),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function connectionEndpointSide(conn, endpoint) {
|
|
920
|
+
const field = endpoint === 'source' ? 'fromSide' : 'toSide';
|
|
921
|
+
if (conn[field] && conn[field] !== 'auto') return conn[field];
|
|
922
|
+
return connectionSides(conn)[field];
|
|
923
|
+
}
|
|
924
|
+
|
|
260
925
|
function pathFor(conn) {
|
|
261
926
|
if (pathCache.has(conn)) return pathCache.get(conn);
|
|
262
927
|
const from = components.get(conn.from);
|
|
263
928
|
const to = components.get(conn.to);
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const
|
|
929
|
+
const ports = automaticPorts.get(conn);
|
|
930
|
+
const { fromSide, toSide } = connectionSides(conn);
|
|
931
|
+
const baseStart = ports?.from || anchor(from, fromSide);
|
|
932
|
+
const baseEnd = ports?.to || anchor(to, toSide);
|
|
933
|
+
const { start, end } = alignFacingPorts(
|
|
934
|
+
conn,
|
|
935
|
+
from,
|
|
936
|
+
to,
|
|
937
|
+
baseStart,
|
|
938
|
+
baseEnd,
|
|
939
|
+
fromSide,
|
|
940
|
+
toSide,
|
|
941
|
+
ports,
|
|
942
|
+
);
|
|
943
|
+
const points = [start, ...routeVia(conn, from, to, start, end, fromSide, toSide), end];
|
|
267
944
|
const routed = { d: roundedPath(points, 8), points };
|
|
268
945
|
pathCache.set(conn, routed);
|
|
269
946
|
return routed;
|
|
270
947
|
}
|
|
271
948
|
|
|
272
949
|
// ---- Rendering ---------------------------------------------------------------
|
|
273
|
-
function
|
|
950
|
+
function renderBoundaryFrame(b, index) {
|
|
274
951
|
const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
|
|
275
|
-
const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
|
|
276
952
|
const rx = b.kind === 'security-group' ? 8 : 12;
|
|
277
|
-
return ` <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"
|
|
278
|
-
|
|
953
|
+
return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-id="${index}" data-composition-frame-label="${esc(b.label)}" x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"/>`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function renderBoundaryLabel(b, index) {
|
|
957
|
+
const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
|
|
958
|
+
return ` <g data-graph-role="structural-frame-label" data-composition-frame-id="${index}" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-label="${esc(b.label)}">
|
|
959
|
+
<rect data-graph-role="structural-frame-label-mask" x="${b.title.x}" y="${b.title.y}" width="${b.title.width}" height="${b.title.height}" rx="3" class="c-mask"/>
|
|
960
|
+
<text data-boundary-label="" x="${b.title.x + 4}" y="${b.title.y + b.title.baselineOffset}" class="${labelCls}" font-size="${b.title.fontSize}" font-weight="600">${esc(b.label)}</text>
|
|
961
|
+
</g>`;
|
|
279
962
|
}
|
|
280
963
|
|
|
281
964
|
function renderConnectionPath(conn, index) {
|
|
282
965
|
const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
|
|
283
966
|
const routed = pathFor(conn);
|
|
284
967
|
const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
|
|
285
|
-
return ` <path d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
968
|
+
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})"/>`;
|
|
286
969
|
}
|
|
287
970
|
|
|
288
|
-
function renderConnectionLabel(conn) {
|
|
971
|
+
function renderConnectionLabel(conn, index) {
|
|
289
972
|
if (!conn.label) return '';
|
|
290
973
|
const [lx, ly] = labelPoint(conn, pathFor(conn).points);
|
|
291
974
|
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
|
|
292
|
-
return ` <
|
|
293
|
-
|
|
975
|
+
return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
|
|
976
|
+
<rect x="${lx - w / 2}" y="${ly - 10}" width="${w}" height="14" rx="3" class="c-mask"/>
|
|
977
|
+
<text x="${lx}" y="${ly}" class="${variantAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
|
|
978
|
+
</g>`;
|
|
294
979
|
}
|
|
295
980
|
|
|
296
981
|
function renderComponent(c) {
|
|
@@ -300,47 +985,65 @@ function renderComponent(c) {
|
|
|
300
985
|
const hasSub = c.sublabel != null && c.sublabel !== '';
|
|
301
986
|
const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4;
|
|
302
987
|
const sub = hasSub
|
|
303
|
-
? `\n <text x="${cx}" y="${c.y + c.height / 2 + 14}" class="t-muted" font-size="
|
|
988
|
+
? `\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>`
|
|
304
989
|
: '';
|
|
305
990
|
const tag = c.tag
|
|
306
|
-
? `\n <text x="${cx}" y="${c.y + c.height - 8}" class="${accent}" font-size="
|
|
991
|
+
? `\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>`
|
|
307
992
|
: '';
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
993
|
+
const brand = renderBrandMark(c, { x: c.x + c.width - 22, y: c.y + 6 });
|
|
994
|
+
const labelFontSize = fittedNodeFontSize(c.label, brandLabelFitWidth(c, c.width), 11, 8);
|
|
995
|
+
const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
|
|
996
|
+
return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
|
|
997
|
+
${focusNodeTitle(c.label, passport)}
|
|
998
|
+
<rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="c-mask"/>
|
|
999
|
+
<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"/>
|
|
1000
|
+
${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
|
|
1001
|
+
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${cx}" y="${labelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(c.label)}</text>${sub}${tag}
|
|
1002
|
+
</g>`;
|
|
311
1003
|
}
|
|
312
1004
|
|
|
313
|
-
// Auto legend: one swatch per component type actually used, left to right.
|
|
314
|
-
const TYPE_LABELS = {
|
|
315
|
-
frontend: 'Frontend', backend: 'Backend', database: 'Database', cloud: 'Cloud',
|
|
316
|
-
security: 'Security', messagebus: 'Message bus', external: 'External',
|
|
317
|
-
};
|
|
318
1005
|
function renderLegend() {
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1006
|
+
const entries = architectureLegendEntries;
|
|
1007
|
+
const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
|
|
1008
|
+
pointsFor: (connection) => pathFor(connection).points,
|
|
1009
|
+
labelRectFor: (connection) => {
|
|
1010
|
+
if (!connection.label) return null;
|
|
1011
|
+
const [x, y] = labelPoint(connection, pathFor(connection).points);
|
|
1012
|
+
const width = Math.max(30, textUnits(connection.label) * 4.8 + 10);
|
|
1013
|
+
return { x: x - width / 2, y: y - 10, width, height: 14 };
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
const contentBottom = Math.max(
|
|
1017
|
+
0,
|
|
1018
|
+
...[...components.values()].map((component) => component.y + component.height),
|
|
1019
|
+
...boundaries.map((boundary) => boundary.y + boundary.height),
|
|
1020
|
+
);
|
|
1021
|
+
return renderResolvedLegend({
|
|
1022
|
+
entries,
|
|
1023
|
+
locale: arch.meta.locale,
|
|
1024
|
+
layout: {
|
|
1025
|
+
x: layout.margin,
|
|
1026
|
+
baselineY: legendY(),
|
|
1027
|
+
width: viewBox[0] - layout.margin * 2,
|
|
1028
|
+
minTitleY: contentBottom + 8,
|
|
1029
|
+
obstacles: relationshipObstacles,
|
|
1030
|
+
unfit: arch.meta?.legend === undefined ? 'hide' : 'error',
|
|
1031
|
+
diagramType: 'architecture',
|
|
1032
|
+
},
|
|
1033
|
+
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"/>`,
|
|
1034
|
+
});
|
|
333
1035
|
}
|
|
334
1036
|
|
|
335
1037
|
function renderSvg() {
|
|
336
|
-
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta
|
|
1038
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)}>
|
|
1039
|
+
${svgAccessibleText(arch.meta, 'architecture')}
|
|
337
1040
|
${renderDefinitions()}
|
|
338
1041
|
|
|
339
1042
|
<!-- Background Grid -->
|
|
340
1043
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
341
1044
|
|
|
342
1045
|
<!-- Boundaries (behind everything) -->
|
|
343
|
-
${boundaries.map(
|
|
1046
|
+
${boundaries.map(renderBoundaryFrame).join('\n\n')}
|
|
344
1047
|
|
|
345
1048
|
<!-- Connection paths (before components for correct z-order) -->
|
|
346
1049
|
${asArray(arch.connections).map(renderConnectionPath).join('\n')}
|
|
@@ -351,6 +1054,9 @@ ${[...components.values()].map(renderComponent).join('\n\n')}
|
|
|
351
1054
|
<!-- Connection labels -->
|
|
352
1055
|
${asArray(arch.connections).map(renderConnectionLabel).join('\n')}
|
|
353
1056
|
|
|
1057
|
+
<!-- Boundary labels (foreground masks keep routes out of titles) -->
|
|
1058
|
+
${boundaries.map(renderBoundaryLabel).join('\n\n')}
|
|
1059
|
+
|
|
354
1060
|
<!-- Legend -->
|
|
355
1061
|
${renderLegend()}
|
|
356
1062
|
</svg>`;
|
|
@@ -364,8 +1070,9 @@ if (layoutJsonMode) {
|
|
|
364
1070
|
writeDiagram({
|
|
365
1071
|
outPath,
|
|
366
1072
|
template,
|
|
1073
|
+
diagramType: 'architecture',
|
|
367
1074
|
meta: arch.meta,
|
|
368
|
-
footerLabel: 'Architecture diagram',
|
|
369
1075
|
svg: renderSvg(),
|
|
370
1076
|
cards: arch.cards,
|
|
1077
|
+
sourceEvidence,
|
|
371
1078
|
});
|