@stacksjs/defaults 0.75.361 → 0.75.362
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-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +2 -2
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import { NODE_CARD_ATTR, nodeCardStyle } from '../shared/node-card-style.mjs';
|
|
4
5
|
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
|
5
6
|
import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
|
|
6
|
-
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
7
|
+
import { rendererFailure, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
7
8
|
import { legendFootprint, relationshipLegendObstacles, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
|
8
9
|
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
|
9
10
|
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
|
10
11
|
import { minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
|
|
11
12
|
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
12
13
|
import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
|
|
14
|
+
import { createRouter } from './routing.mjs';
|
|
15
|
+
import { placeAutomaticLabels, reservedLabelRect } from '../shared/automatic-labels.mjs';
|
|
16
|
+
import { cleanRouteDetourProblems } from '../shared/route-quality.mjs';
|
|
13
17
|
import {
|
|
14
18
|
asArray,
|
|
15
19
|
isFinitePoint,
|
|
@@ -19,27 +23,22 @@ import {
|
|
|
19
23
|
cleanFlowProblems,
|
|
20
24
|
cleanCrossingProblems,
|
|
21
25
|
cleanAmbiguousCorridorProblems,
|
|
26
|
+
collectArrowheadCollisions,
|
|
22
27
|
cleanBorderRunProblems,
|
|
23
28
|
cleanRouteRhythmProblems,
|
|
24
29
|
cleanLabelRouteClearanceProblems,
|
|
30
|
+
cleanLabelCanvasContainmentProblems,
|
|
25
31
|
suggestLabelObstacleFix,
|
|
32
|
+
componentSeparationOptions,
|
|
26
33
|
suggestComponentSeparation,
|
|
27
|
-
anchor,
|
|
28
|
-
automaticPortSpread,
|
|
29
|
-
automaticPortRhythmBridge,
|
|
30
|
-
defaultFromSide,
|
|
31
|
-
defaultToSide,
|
|
32
|
-
chosenSide,
|
|
33
|
-
routeHonorsEndpointSides,
|
|
34
|
-
normalizeRoutePoints,
|
|
35
34
|
polylinePath,
|
|
36
35
|
routePointsValue,
|
|
37
|
-
|
|
36
|
+
authoredStraightRouteAttrs,
|
|
38
37
|
labelPoint,
|
|
39
38
|
componentFill,
|
|
40
39
|
componentText,
|
|
41
40
|
arrowClassMap,
|
|
42
|
-
|
|
41
|
+
edgeLabelAccent,
|
|
43
42
|
} from '../shared/geometry.mjs';
|
|
44
43
|
|
|
45
44
|
const componentTextFit = {
|
|
@@ -65,7 +64,7 @@ const layout = {
|
|
|
65
64
|
defaultW: 120,
|
|
66
65
|
defaultH: 60,
|
|
67
66
|
margin: 40,
|
|
68
|
-
// Boundary padding
|
|
67
|
+
// Boundary padding — the 30/50 rule that was a hand-arithmetic footgun
|
|
69
68
|
// (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
|
|
70
69
|
boundaryPad: 30,
|
|
71
70
|
boundaryExtraBottom: 20,
|
|
@@ -111,14 +110,24 @@ for (const [index, c] of asArray(arch.components).entries()) {
|
|
|
111
110
|
function boundaryRect(boundary) {
|
|
112
111
|
const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean);
|
|
113
112
|
if (!members.length) return null;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
let minX = Infinity;
|
|
114
|
+
let minY = Infinity;
|
|
115
|
+
let maxX = -Infinity;
|
|
116
|
+
let maxY = -Infinity;
|
|
117
|
+
for (const member of members) {
|
|
118
|
+
minX = Math.min(minX, member.x);
|
|
119
|
+
minY = Math.min(minY, member.y);
|
|
120
|
+
maxX = Math.max(maxX, member.x + member.width);
|
|
121
|
+
maxY = Math.max(maxY, member.y + member.height);
|
|
122
|
+
}
|
|
118
123
|
const pad = boundary.pad ?? layout.boundaryPad;
|
|
119
124
|
const topPad = Math.max(
|
|
120
125
|
pad,
|
|
121
126
|
layout.boundaryLabelBaseline + layout.boundaryLabelClearance,
|
|
127
|
+
// Routes must avoid the title rail that the final frame will contain.
|
|
128
|
+
enforcesBoundaryTitleComposition
|
|
129
|
+
? layout.boundaryLabelMaskHeight + layout.boundaryLabelClearance + layout.boundaryLabelFrameInset
|
|
130
|
+
: 0,
|
|
122
131
|
);
|
|
123
132
|
return {
|
|
124
133
|
...boundary,
|
|
@@ -148,17 +157,40 @@ const architectureLegendEntries = resolveLegend(
|
|
|
148
157
|
new Set([...components.values()].map((component) => component.type)),
|
|
149
158
|
);
|
|
150
159
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
160
|
+
// One source for connection label geometry: the rect the containment rule
|
|
161
|
+
// measures is the rect the SVG mask draws, the auto canvas covers, the legend
|
|
162
|
+
// avoids, and the layout report publishes.
|
|
163
|
+
const resolvedLabelPoints = new Map();
|
|
164
|
+
function connectionLabelBox(conn) {
|
|
165
|
+
if (!conn.label) return null;
|
|
166
|
+
return connectionLabelBoxAt(conn, resolvedLabelPoints.get(conn) || labelPoint(conn, pathFor(conn).points));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function connectionLabelBoxAt(conn, [lx, ly]) {
|
|
170
|
+
const width = Math.max(30, textUnits(conn.label) * 4.8 + 10);
|
|
171
|
+
return { x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function connectionLabelRects() {
|
|
175
|
+
const rects = [];
|
|
176
|
+
for (const [relationIndex, conn] of asArray(arch.connections).entries()) {
|
|
177
|
+
if (!components.has(conn.from) || !components.has(conn.to)) continue;
|
|
178
|
+
const box = connectionLabelBox(conn);
|
|
179
|
+
if (!box) continue;
|
|
180
|
+
rects.push({ relation: conn, relationIndex, label: conn.label, ...box });
|
|
181
|
+
}
|
|
182
|
+
return rects;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function autoViewBoxFor(candidateBoundaries, extraRects = []) {
|
|
186
|
+
let maxX = 0;
|
|
187
|
+
let maxY = 0;
|
|
188
|
+
for (const rects of [components.values(), candidateBoundaries, extraRects]) {
|
|
189
|
+
for (const rect of rects) {
|
|
190
|
+
maxX = Math.max(maxX, rect.x + rect.width);
|
|
191
|
+
maxY = Math.max(maxY, rect.y + rect.height);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
162
194
|
let width = Math.ceil(maxX + layout.margin);
|
|
163
195
|
let footprint = legendFootprint(architectureLegendEntries, {
|
|
164
196
|
width: Math.max(1, width - layout.margin * 2),
|
|
@@ -179,7 +211,7 @@ function resolvedViewBoxWidth(candidateBoundaries) {
|
|
|
179
211
|
if (Array.isArray(arch.meta?.viewBox) && Number.isFinite(arch.meta.viewBox[0])) {
|
|
180
212
|
return arch.meta.viewBox[0];
|
|
181
213
|
}
|
|
182
|
-
return autoViewBoxFor(candidateBoundaries)[0];
|
|
214
|
+
return autoViewBoxFor(candidateBoundaries, connectionGeometry)[0];
|
|
183
215
|
}
|
|
184
216
|
|
|
185
217
|
function expandBoundaryForReadableTitle(boundary, minimumFontSize) {
|
|
@@ -227,6 +259,42 @@ function horizontalOverlap(left, right) {
|
|
|
227
259
|
return left.x < right.x + right.width && left.x + left.width > right.x;
|
|
228
260
|
}
|
|
229
261
|
|
|
262
|
+
// A route through a title disappears under its mask and reads as struck-through
|
|
263
|
+
// text. Keep the left rail position when it is clear; otherwise slide the title
|
|
264
|
+
// along its rail to the nearest clear position inside the frame.
|
|
265
|
+
let titleRouteSegments = null;
|
|
266
|
+
function slideTitleOffRoutes(boundary, title, placedTitles) {
|
|
267
|
+
if (!enforcesBoundaryTitleComposition) return;
|
|
268
|
+
titleRouteSegments ||= asArray(arch.connections)
|
|
269
|
+
.filter((conn) => components.has(conn.from) && components.has(conn.to))
|
|
270
|
+
.flatMap((conn) => {
|
|
271
|
+
const points = pathFor(conn).points;
|
|
272
|
+
return points.slice(1).map((end, index) => ({ start: points[index], end }));
|
|
273
|
+
});
|
|
274
|
+
const clear = (x) => {
|
|
275
|
+
const rect = { ...title, x };
|
|
276
|
+
return !titleRouteSegments.some((segment) => segmentIntersectsRect(segment, rect, 2))
|
|
277
|
+
&& ![...placedTitles, ...components.values(), ...connectionLabels].some((other) => rectsOverlap(rect, other));
|
|
278
|
+
};
|
|
279
|
+
if (clear(title.x)) return;
|
|
280
|
+
const left = boundary.x + layout.boundaryLabelFrameInset;
|
|
281
|
+
const right = boundary.x + boundary.width - layout.boundaryLabelFrameInset - title.width;
|
|
282
|
+
const band = titleRouteSegments.filter(({ start, end }) => (
|
|
283
|
+
Math.max(start[1], end[1]) >= title.y - 2 && Math.min(start[1], end[1]) <= title.y + title.height + 2
|
|
284
|
+
));
|
|
285
|
+
const labelBlockers = connectionLabels.filter((label) => (
|
|
286
|
+
label.y < title.y + title.height && label.y + label.height > title.y
|
|
287
|
+
));
|
|
288
|
+
const x = [right, ...band.flatMap(({ start, end }) => [
|
|
289
|
+
Math.max(start[0], end[0]) + 8, Math.min(start[0], end[0]) - 8 - title.width,
|
|
290
|
+
]), ...labelBlockers.flatMap((label) => [
|
|
291
|
+
label.x + label.width + 8, label.x - 8 - title.width,
|
|
292
|
+
])].filter((value) => value >= left && value <= right)
|
|
293
|
+
.sort((a, b) => a - b)
|
|
294
|
+
.find(clear);
|
|
295
|
+
if (x !== undefined) title.x = x;
|
|
296
|
+
}
|
|
297
|
+
|
|
230
298
|
function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
|
|
231
299
|
const placedTitles = [];
|
|
232
300
|
const measured = new Map();
|
|
@@ -250,10 +318,12 @@ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
|
|
|
250
318
|
...components.values(),
|
|
251
319
|
].filter((candidate) => horizontalOverlap(title, candidate) && rectsOverlap(title, candidate));
|
|
252
320
|
if (!blockers.length) break;
|
|
253
|
-
title.y =
|
|
254
|
-
|
|
321
|
+
title.y = blockers.reduce(
|
|
322
|
+
(min, blocker) => Math.min(min, blocker.y - layout.boundaryLabelRailGap - title.height),
|
|
323
|
+
Infinity,
|
|
255
324
|
);
|
|
256
325
|
}
|
|
326
|
+
slideTitleOffRoutes(boundary, title, placedTitles);
|
|
257
327
|
placedTitles.push(title);
|
|
258
328
|
measured.set(index, { boundary, title });
|
|
259
329
|
}
|
|
@@ -276,7 +346,52 @@ function layoutBoundaryTitles(rawBoundaries, minimumFontSize) {
|
|
|
276
346
|
});
|
|
277
347
|
}
|
|
278
348
|
|
|
349
|
+
// ---- Routing state ----------------------------------------------------------
|
|
350
|
+
// Initialized before the boundary-title work below: connection label rects are
|
|
351
|
+
// part of the derived canvas, so the title convergence must measure the same
|
|
352
|
+
// width the diagram actually renders into (a title sized for a narrower canvas
|
|
353
|
+
// would fall below the desktop-readability floor once labels grow it). Routing
|
|
354
|
+
// reads components, connections and the member-derived boundary frames (so an
|
|
355
|
+
// automatic route never borrows a frame border as its corridor), never the
|
|
356
|
+
// title-expanded frames or the viewBox.
|
|
279
357
|
const rawBoundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
|
|
358
|
+
const routerOptions = {
|
|
359
|
+
distinctAutomaticPorts: true,
|
|
360
|
+
preferReadableRoutes: true,
|
|
361
|
+
// A single outward stub below the scene can remove a crossing without
|
|
362
|
+
// widening the canvas or shrinking its text. Fixed canvases keep their bounds.
|
|
363
|
+
readabilityBottomMargin: arch.meta?.viewBox ? 0 : 24,
|
|
364
|
+
frames: rawBoundaries.map((boundary) => ({
|
|
365
|
+
...boundary,
|
|
366
|
+
radius: boundary.kind === 'security-group' ? 8 : 12,
|
|
367
|
+
})),
|
|
368
|
+
labelRectFor: (conn, points, { routes, labels }) => (conn.label ? reservedLabelRect({
|
|
369
|
+
label: { relation: conn, label: conn.label, ...connectionLabelBoxAt(conn, labelPoint(conn, points)) },
|
|
370
|
+
points,
|
|
371
|
+
routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
|
|
372
|
+
labels,
|
|
373
|
+
components: [...components.values()],
|
|
374
|
+
}) : null),
|
|
375
|
+
};
|
|
376
|
+
const { pathFor, connectionSides, connectionEndpointSide } = createRouter(components, arch.connections, routerOptions);
|
|
377
|
+
function hasAutomaticRouteGeometry(connection) {
|
|
378
|
+
return !Array.isArray(connection?.via)
|
|
379
|
+
&& (!connection?.route || connection.route === 'auto')
|
|
380
|
+
&& connection?.channelX === undefined
|
|
381
|
+
&& connection?.channelY === undefined;
|
|
382
|
+
}
|
|
383
|
+
// The auto canvas has to cover these rects; an authored viewBox is never
|
|
384
|
+
// resized to fit them — there the containment rule reports the clipping.
|
|
385
|
+
let connectionLabels = connectionLabelRects();
|
|
386
|
+
// Unlabelled outer corridors are geometry too. Fitting only nodes and labels
|
|
387
|
+
// can clip a valid explicit via route while every browser overflow check passes.
|
|
388
|
+
const connectionGeometry = [
|
|
389
|
+
...connectionLabels,
|
|
390
|
+
...asArray(arch.connections)
|
|
391
|
+
.filter((conn) => components.has(conn.from) && components.has(conn.to))
|
|
392
|
+
.flatMap((conn) => pathFor(conn).points.map(([x, y]) => ({ x, y, width: 0, height: 0 }))),
|
|
393
|
+
];
|
|
394
|
+
|
|
280
395
|
function resolveBoundaryTitles() {
|
|
281
396
|
if (!enforcesBoundaryTitleComposition || rawBoundaries.length === 0) {
|
|
282
397
|
return {
|
|
@@ -308,7 +423,7 @@ function resolveBoundaryTitles() {
|
|
|
308
423
|
const finalViewBoxWidth = resolvedViewBoxWidth(candidateBoundaries);
|
|
309
424
|
return {
|
|
310
425
|
boundaries: candidateBoundaries,
|
|
311
|
-
readabilityProblem: `[composition/desktop-readability] Boundary title layout did not converge after ${maximumIterations} iterations for the final ${finalViewBoxWidth}px viewBox
|
|
426
|
+
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
427
|
};
|
|
313
428
|
}
|
|
314
429
|
|
|
@@ -330,12 +445,48 @@ function componentContext(component) {
|
|
|
330
445
|
}
|
|
331
446
|
|
|
332
447
|
// ---- Auto viewBox: fit all geometry + the measured resolved legend ----------
|
|
333
|
-
|
|
448
|
+
// Connection labels are diagram content, so an auto canvas that stopped at the
|
|
449
|
+
// component/boundary bbox would clip them; the label rects join the fit here
|
|
450
|
+
// and in the title convergence above, which sizes fonts for this same width.
|
|
451
|
+
const viewBox = arch.meta?.viewBox || autoViewBoxFor(boundaries, connectionGeometry);
|
|
334
452
|
const legendY = () => viewBox[1] - 16;
|
|
335
453
|
|
|
454
|
+
// Fit titles and canvas from the original geometry first. Fallback labels must
|
|
455
|
+
// fit inside that canvas, so moving a label cannot trigger title reflow or a
|
|
456
|
+
// canvas/label feedback loop. Keep standard and every authored label control.
|
|
457
|
+
if (arch.meta?.quality_profile === 'showcase') {
|
|
458
|
+
connectionLabels = placeAutomaticLabels({
|
|
459
|
+
keepFallbackNearRoute: true,
|
|
460
|
+
labels: connectionLabels,
|
|
461
|
+
routes: asArray(arch.connections).flatMap((conn, relationIndex) => (
|
|
462
|
+
components.has(conn.from) && components.has(conn.to)
|
|
463
|
+
? [{ relationIndex, points: pathFor(conn).points }] : []
|
|
464
|
+
)),
|
|
465
|
+
components: [...components.values()],
|
|
466
|
+
titles: boundaries.map(boundary => boundary.title),
|
|
467
|
+
viewBox,
|
|
468
|
+
// Leave the resolved legend band available; moving a label must not hide
|
|
469
|
+
// an otherwise visible legend. Existing labels keep their placement.
|
|
470
|
+
placementBottom: architectureLegendEntries.length
|
|
471
|
+
? legendY() - 32 - legendFootprint(architectureLegendEntries, { width: viewBox[0] - layout.margin * 2 }).extraHeight
|
|
472
|
+
: viewBox[1],
|
|
473
|
+
});
|
|
474
|
+
for (const rect of connectionLabels) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
|
|
475
|
+
}
|
|
476
|
+
|
|
336
477
|
// ---- Validation: mechanical correctness, never layout taste -----------------
|
|
478
|
+
function componentDiagnosticBox(component) {
|
|
479
|
+
return {
|
|
480
|
+
...componentBox(component),
|
|
481
|
+
x: component.x,
|
|
482
|
+
y: component.y,
|
|
483
|
+
...(Array.isArray(component.pos) ? { pos: [...component.pos] } : {}),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
337
487
|
function validateArchitecture() {
|
|
338
488
|
const problems = [];
|
|
489
|
+
const diagnostics = [];
|
|
339
490
|
if (resolvedBoundaryTitles.readabilityProblem) {
|
|
340
491
|
problems.push(resolvedBoundaryTitles.readabilityProblem);
|
|
341
492
|
}
|
|
@@ -353,19 +504,80 @@ function validateArchitecture() {
|
|
|
353
504
|
|
|
354
505
|
for (const c of components.values()) {
|
|
355
506
|
if (!isFinitePoint(c.x, c.y, c.width, c.height)) {
|
|
356
|
-
|
|
507
|
+
const message = `Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`;
|
|
508
|
+
diagnostics.push({
|
|
509
|
+
code: 'architecture/non-finite-component-geometry', severity: 'error', message,
|
|
510
|
+
subject: { diagramType: 'architecture', nodeId: c.id },
|
|
511
|
+
evidence: { pos: c.pos ?? null, size: [c.width, c.height] },
|
|
512
|
+
supportedFixes: [`replace component "${c.id}" pos and size with finite [number, number] values`],
|
|
513
|
+
});
|
|
514
|
+
problems.push(message);
|
|
357
515
|
continue;
|
|
358
516
|
}
|
|
359
517
|
if (c.width <= 0 || c.height <= 0) {
|
|
360
|
-
|
|
518
|
+
const message = `Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`;
|
|
519
|
+
diagnostics.push({
|
|
520
|
+
code: 'architecture/invalid-component-size', severity: 'error', message,
|
|
521
|
+
subject: { diagramType: 'architecture', nodeId: c.id },
|
|
522
|
+
evidence: { width: c.width, height: c.height },
|
|
523
|
+
supportedFixes: [`set component "${c.id}" width and height to values greater than 0`],
|
|
524
|
+
});
|
|
525
|
+
problems.push(message);
|
|
361
526
|
continue;
|
|
362
527
|
}
|
|
363
528
|
if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) {
|
|
364
|
-
|
|
529
|
+
const message = `Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`;
|
|
530
|
+
const overflow = {
|
|
531
|
+
left: Math.max(0, -c.x),
|
|
532
|
+
top: Math.max(0, -c.y),
|
|
533
|
+
right: Math.max(0, c.x + c.width - viewBox[0]),
|
|
534
|
+
bottom: Math.max(0, c.y + c.height - viewBox[1]),
|
|
535
|
+
};
|
|
536
|
+
const widerThanCanvas = overflow.left > 0 && overflow.right > 0;
|
|
537
|
+
const tallerThanCanvas = overflow.top > 0 && overflow.bottom > 0;
|
|
538
|
+
const supportedFixes = [];
|
|
539
|
+
// Move by exactly the overflow: it lands the edge flush (x + (-x) is exact
|
|
540
|
+
// in IEEE 754), while a rounded-up move pushes the opposite edge past a
|
|
541
|
+
// tight canvas and a rounded-down one leaves the overflow in place. When
|
|
542
|
+
// the component is larger than the canvas, the move must shrink it too.
|
|
543
|
+
if (overflow.left > 0) {
|
|
544
|
+
const shrink = Math.max(0, c.width - viewBox[0]);
|
|
545
|
+
supportedFixes.push(shrink > 0
|
|
546
|
+
? `move component "${c.id}" right by ${overflow.left}px and shrink its width by at least ${shrink}px`
|
|
547
|
+
: `move component "${c.id}" right by ${overflow.left}px`);
|
|
548
|
+
}
|
|
549
|
+
if (overflow.top > 0) {
|
|
550
|
+
const shrink = Math.max(0, c.height - viewBox[1]);
|
|
551
|
+
supportedFixes.push(shrink > 0
|
|
552
|
+
? `move component "${c.id}" down by ${overflow.top}px and shrink its height by at least ${shrink}px`
|
|
553
|
+
: `move component "${c.id}" down by ${overflow.top}px`);
|
|
554
|
+
}
|
|
555
|
+
if ((overflow.right > 0 && !widerThanCanvas) || (overflow.bottom > 0 && !tallerThanCanvas)) {
|
|
556
|
+
const coveredWidth = overflow.right > 0 && !widerThanCanvas ? Math.ceil(c.x + c.width) : viewBox[0];
|
|
557
|
+
const coveredHeight = overflow.bottom > 0 && !tallerThanCanvas ? Math.ceil(c.y + c.height) : viewBox[1];
|
|
558
|
+
supportedFixes.push(`set meta.viewBox to at least [${Math.max(viewBox[0], coveredWidth)}, ${Math.max(viewBox[1], coveredHeight)}]`);
|
|
559
|
+
}
|
|
560
|
+
diagnostics.push({
|
|
561
|
+
code: 'layout/component-out-of-bounds', severity: 'error', message,
|
|
562
|
+
subject: { diagramType: 'architecture', nodeId: c.id },
|
|
563
|
+
evidence: { bounds: componentDiagnosticBox(c), viewBox: [...viewBox], overflow },
|
|
564
|
+
supportedFixes,
|
|
565
|
+
});
|
|
566
|
+
problems.push(message);
|
|
365
567
|
}
|
|
366
568
|
const estLabelW = textUnits(c.label) * 6.6;
|
|
367
569
|
if (estLabelW > c.width + 8) {
|
|
368
|
-
|
|
570
|
+
const message = `Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label or widen size.`;
|
|
571
|
+
diagnostics.push({
|
|
572
|
+
code: 'architecture/component-label-overflow', severity: 'error', message,
|
|
573
|
+
subject: { diagramType: 'architecture', nodeId: c.id },
|
|
574
|
+
evidence: { text: c.label, estimatedWidthPx: Math.round(estLabelW), availableWidthPx: c.width },
|
|
575
|
+
supportedFixes: [
|
|
576
|
+
`shorten component "${c.id}" label "${c.label}" while preserving its meaning`,
|
|
577
|
+
`or widen component "${c.id}" to at least ${Math.ceil(estLabelW - 8)}px`,
|
|
578
|
+
],
|
|
579
|
+
});
|
|
580
|
+
problems.push(message);
|
|
369
581
|
}
|
|
370
582
|
const brandRailProblem = brandTopRailProblem(c, c.width, 8, 'Component');
|
|
371
583
|
if (brandRailProblem) problems.push(brandRailProblem);
|
|
@@ -379,17 +591,40 @@ function validateArchitecture() {
|
|
|
379
591
|
if (!value) continue;
|
|
380
592
|
const minimumW = minimumNodeTextWidth(value, minimum);
|
|
381
593
|
if (minimumW > availableTextW) {
|
|
382
|
-
|
|
594
|
+
const fieldKey = field.toLowerCase();
|
|
595
|
+
const message = `${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but component "${c.id}" provides ${availableTextW}px — shorten the ${fieldKey} or widen size.`;
|
|
596
|
+
diagnostics.push({
|
|
597
|
+
code: `architecture/component-${fieldKey}-overflow`, severity: 'error', message,
|
|
598
|
+
subject: { diagramType: 'architecture', nodeId: c.id },
|
|
599
|
+
evidence: { text: value, minimumFontPx: minimum, requiredWidthPx: Math.ceil(minimumW), availableWidthPx: availableTextW },
|
|
600
|
+
supportedFixes: [
|
|
601
|
+
`shorten component "${c.id}" ${fieldKey} "${value}" while preserving its meaning`,
|
|
602
|
+
`or widen component "${c.id}" so the ${fieldKey} keeps at least ${Math.ceil(minimumW)}px of text width at the ${minimum}px legible minimum`,
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
problems.push(message);
|
|
383
606
|
}
|
|
384
607
|
}
|
|
385
608
|
}
|
|
386
609
|
|
|
387
|
-
// Component overlap
|
|
610
|
+
// Component overlap — the highest-traffic hand-placement failure mode.
|
|
388
611
|
const list = [...components.values()];
|
|
389
612
|
for (let i = 0; i < list.length; i += 1) {
|
|
390
613
|
for (let j = i + 1; j < list.length; j += 1) {
|
|
391
614
|
if (rectsOverlap(list[i], list[j], 8)) {
|
|
392
|
-
|
|
615
|
+
const separation = componentSeparationOptions(list[i], list[j], 8);
|
|
616
|
+
const message = `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)}`;
|
|
617
|
+
diagnostics.push({
|
|
618
|
+
code: 'layout/component-overlap', severity: 'error', message,
|
|
619
|
+
subject: { diagramType: 'architecture', nodeId: list[i].id },
|
|
620
|
+
evidence: { otherId: list[j].id, minimumGapPx: 8, boxes: [componentDiagnosticBox(list[i]), componentDiagnosticBox(list[j])] },
|
|
621
|
+
supportedFixes: [
|
|
622
|
+
`move component "${list[j].id}" pos to [${separation[0].pos[0]}, ${separation[0].pos[1]}] (${separation[0].relation})`,
|
|
623
|
+
`or move component "${list[j].id}" pos to [${separation[1].pos[0]}, ${separation[1].pos[1]}] (${separation[1].relation})`,
|
|
624
|
+
`or shrink "${list[i].id}" or "${list[j].id}" while keeping every label and sublabel readable`,
|
|
625
|
+
],
|
|
626
|
+
});
|
|
627
|
+
problems.push(message);
|
|
393
628
|
}
|
|
394
629
|
}
|
|
395
630
|
}
|
|
@@ -406,24 +641,24 @@ function validateArchitecture() {
|
|
|
406
641
|
if (boundary.title.minimumWidth > boundary.title.availableWidth) {
|
|
407
642
|
problems.push(
|
|
408
643
|
`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
|
|
644
|
+
+ `${Number(boundary.title.minimumFontSize.toFixed(2))}px desktop-readable source minimum, but its frame provides ${Math.floor(boundary.title.availableWidth)}px — `
|
|
410
645
|
+ 'shorten the boundary label, increase pad, or widen the wrapped component layout.',
|
|
411
646
|
);
|
|
412
647
|
}
|
|
413
648
|
if (!rectContains(boundary, boundary.title)) {
|
|
414
649
|
problems.push(
|
|
415
|
-
`Boundary label "${boundary.label}" extends outside its final frame
|
|
650
|
+
`Boundary label "${boundary.label}" extends outside its final frame — shorten the label or increase boundary pad.`,
|
|
416
651
|
);
|
|
417
652
|
}
|
|
418
653
|
if (!rectContains(viewBoxRect, boundary.title)) {
|
|
419
654
|
problems.push(
|
|
420
|
-
`Boundary label "${boundary.label}" extends outside the viewBox
|
|
655
|
+
`Boundary label "${boundary.label}" extends outside the viewBox — move wrapped components away from the canvas edge, shorten the label, or increase the viewBox.`,
|
|
421
656
|
);
|
|
422
657
|
}
|
|
423
658
|
for (const component of components.values()) {
|
|
424
659
|
if (!rectsOverlap(boundary.title, component)) continue;
|
|
425
660
|
problems.push(
|
|
426
|
-
`Boundary label "${boundary.label}" overlaps component "${component.id}"
|
|
661
|
+
`Boundary label "${boundary.label}" overlaps component "${component.id}" — move the component, increase boundary title space, or shorten the label.`,
|
|
427
662
|
);
|
|
428
663
|
}
|
|
429
664
|
}
|
|
@@ -434,7 +669,7 @@ function validateArchitecture() {
|
|
|
434
669
|
const right = boundaries[rightIndex];
|
|
435
670
|
if (enforcesBoundaryTitleComposition && rectsOverlap(left.title, right.title)) {
|
|
436
671
|
problems.push(
|
|
437
|
-
`Boundary labels "${left.label}" and "${right.label}" overlap
|
|
672
|
+
`Boundary labels "${left.label}" and "${right.label}" overlap — shorten a label or increase boundary title space.`,
|
|
438
673
|
);
|
|
439
674
|
}
|
|
440
675
|
// Ordinary architecture boundaries are sets, not an implied ownership
|
|
@@ -454,7 +689,7 @@ function validateArchitecture() {
|
|
|
454
689
|
`Boundary "${left.label}" crosses boundary "${right.label}" because their memberships partially overlap `
|
|
455
690
|
+ `(shared: ${shared.map((id) => `"${id}"`).join(', ')}; `
|
|
456
691
|
+ `only in "${left.label}": ${leftOnly.map((id) => `"${id}"`).join(', ')}; `
|
|
457
|
-
+ `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')})
|
|
692
|
+
+ `only in "${right.label}": ${rightOnly.map((id) => `"${id}"`).join(', ')}) — `
|
|
458
693
|
+ 'keep one boundary fully nested by removing outside members, or split the boundary.',
|
|
459
694
|
);
|
|
460
695
|
continue;
|
|
@@ -465,7 +700,7 @@ function validateArchitecture() {
|
|
|
465
700
|
const rightContainsLeft = rectContains(right, left);
|
|
466
701
|
if (!leftContainsRight && !rightContainsLeft) {
|
|
467
702
|
problems.push(
|
|
468
|
-
`Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap
|
|
703
|
+
`Boundary "${left.label}" and boundary "${right.label}" final frames partially overlap — `
|
|
469
704
|
+ 'adjust wraps, pad, or component positions so the frames are disjoint or one fully contains the other.',
|
|
470
705
|
);
|
|
471
706
|
continue;
|
|
@@ -473,7 +708,7 @@ function validateArchitecture() {
|
|
|
473
708
|
|
|
474
709
|
if (!shared.length) {
|
|
475
710
|
problems.push(
|
|
476
|
-
`Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint
|
|
711
|
+
`Boundary "${left.label}" and boundary "${right.label}" final frames overlap even though their memberships are disjoint — `
|
|
477
712
|
+ 'adjust pad or component positions so the frames are disjoint, or make wraps express the intended nesting.',
|
|
478
713
|
);
|
|
479
714
|
continue;
|
|
@@ -483,7 +718,7 @@ function validateArchitecture() {
|
|
|
483
718
|
|| (rightNested && leftContainsRight);
|
|
484
719
|
if (!containmentMatchesMembership) {
|
|
485
720
|
problems.push(
|
|
486
|
-
`Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership
|
|
721
|
+
`Boundary "${left.label}" and boundary "${right.label}" final frame containment contradicts their wraps membership — `
|
|
487
722
|
+ 'reduce the inner boundary pad, move its components, or correct wraps so geometry and nesting agree.',
|
|
488
723
|
);
|
|
489
724
|
}
|
|
@@ -491,18 +726,133 @@ function validateArchitecture() {
|
|
|
491
726
|
}
|
|
492
727
|
for (const b of boundaries) {
|
|
493
728
|
if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
|
|
494
|
-
|
|
729
|
+
const overflow = {
|
|
730
|
+
left: Math.max(0, -b.x),
|
|
731
|
+
top: Math.max(0, -b.y),
|
|
732
|
+
right: Math.max(0, b.x + b.width - viewBox[0]),
|
|
733
|
+
bottom: Math.max(0, b.y + b.height - viewBox[1]),
|
|
734
|
+
};
|
|
735
|
+
const sides = Object.entries(overflow).filter(([, pixels]) => pixels > 0)
|
|
736
|
+
.map(([side, pixels]) => `${side} by ${Math.ceil(pixels)}px`).join(', ');
|
|
737
|
+
const supportedFixes = [];
|
|
738
|
+
if (overflow.left || overflow.top) {
|
|
739
|
+
supportedFixes.push(`move the wrapped components right by at least ${Math.ceil(overflow.left)}px and down by at least ${Math.ceil(overflow.top)}px, then revalidate connected routes and the opposite canvas sides; enlarging meta.viewBox cannot fix left/top overflow`);
|
|
740
|
+
}
|
|
741
|
+
if (overflow.right || overflow.bottom) {
|
|
742
|
+
supportedFixes.push(`increase meta.viewBox to at least [${Math.ceil(Math.max(viewBox[0], b.x + b.width))}, ${Math.ceil(Math.max(viewBox[1], b.y + b.height))}] for right/bottom overflow, or move the wrapped components inward; revalidate desktop readability`);
|
|
743
|
+
}
|
|
744
|
+
const message = `Boundary "${b.label}" extends outside the viewBox (${sides}) — preserve wraps membership and repair the measured canvas side.`;
|
|
745
|
+
diagnostics.push({
|
|
746
|
+
code: 'layout/boundary-out-of-bounds',
|
|
747
|
+
severity: 'error',
|
|
748
|
+
message,
|
|
749
|
+
subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps } },
|
|
750
|
+
evidence: {
|
|
751
|
+
bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
|
|
752
|
+
viewBox: [...viewBox],
|
|
753
|
+
overflow,
|
|
754
|
+
members: asArray(b.wraps).map((id) => components.get(id)).filter(Boolean).map(componentBox),
|
|
755
|
+
},
|
|
756
|
+
supportedFixes,
|
|
757
|
+
});
|
|
758
|
+
problems.push(message);
|
|
495
759
|
}
|
|
496
760
|
}
|
|
497
761
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
762
|
+
// A boundary frame is drawn as the padded box around its members, so a
|
|
763
|
+
// component that is not in `wraps` but sits wholly inside that box reads as
|
|
764
|
+
// a member. Report it instead of letting the picture claim false ownership.
|
|
765
|
+
// A box the frame border visibly cuts through does not claim membership,
|
|
766
|
+
// and earlier valid inputs depend on that, so only full enclosure fails.
|
|
767
|
+
// This new composition gate applies to showcase; standard keeps its existing
|
|
768
|
+
// acceptance contract while authors can opt into the stricter check.
|
|
769
|
+
if (arch.meta?.quality_profile === 'showcase') {
|
|
770
|
+
for (const b of boundaries) {
|
|
771
|
+
const members = new Set(asArray(b.wraps));
|
|
772
|
+
for (const component of components.values()) {
|
|
773
|
+
if (members.has(component.id) || !rectContains(b, component)) continue;
|
|
774
|
+
const message = `Component "${component.id}" is not wrapped by boundary "${b.label}" but sits inside its frame — `
|
|
775
|
+
+ 'move it outside the frame, place the wrapped members in one compact axis-aligned cluster (no empty bay for a non-member), or add it to wraps only when it truly belongs there.';
|
|
776
|
+
problems.push(message);
|
|
777
|
+
diagnostics.push({
|
|
778
|
+
code: 'layout/boundary-encloses-non-member',
|
|
779
|
+
severity: 'error',
|
|
780
|
+
message,
|
|
781
|
+
subject: { diagramType: 'architecture', boundary: { kind: b.kind, label: b.label, wraps: b.wraps }, component: component.id },
|
|
782
|
+
evidence: {
|
|
783
|
+
bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
|
|
784
|
+
component: componentBox(component),
|
|
785
|
+
},
|
|
786
|
+
supportedFixes: [
|
|
787
|
+
`move "${component.id}" outside the boundary frame`,
|
|
788
|
+
'reposition the wrapped members into one compact axis-aligned cluster (no empty bay) that leaves non-members outside',
|
|
789
|
+
],
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const connectionList = asArray(arch.connections);
|
|
796
|
+
const knownComponentIds = [...components.keys()].sort();
|
|
797
|
+
for (const conn of connectionList) {
|
|
798
|
+
const connIndex = connectionList.indexOf(conn);
|
|
799
|
+
for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
|
|
800
|
+
if (components.has(conn[field])) continue;
|
|
801
|
+
const message = `Connection "${conn.label || conn[field]}" references unknown ${endpoint} "${conn[field]}".`;
|
|
802
|
+
const otherId = conn[field === 'from' ? 'to' : 'from'];
|
|
803
|
+
const neighbors = new Set();
|
|
804
|
+
for (const edge of connectionList) {
|
|
805
|
+
if (edge === conn) continue;
|
|
806
|
+
if (edge.from === otherId) neighbors.add(edge.to);
|
|
807
|
+
if (edge.to === otherId) neighbors.add(edge.from);
|
|
808
|
+
}
|
|
809
|
+
const candidates = knownComponentIds
|
|
810
|
+
.filter((id) => id !== otherId)
|
|
811
|
+
.sort((a, b) => (neighbors.has(b) - neighbors.has(a)) || a.localeCompare(b));
|
|
812
|
+
diagnostics.push({
|
|
813
|
+
code: 'architecture/unknown-endpoint', severity: 'error', message,
|
|
814
|
+
subject: {
|
|
815
|
+
diagramType: 'architecture',
|
|
816
|
+
connection: conn.id ?? null,
|
|
817
|
+
path: `/connections/${connIndex}/${field}`,
|
|
818
|
+
from: conn.from,
|
|
819
|
+
to: conn.to,
|
|
820
|
+
},
|
|
821
|
+
evidence: { endpoint, unknownNodeId: conn[field], availableNodeIds: knownComponentIds },
|
|
822
|
+
supportedFixes: candidates.slice(0, 3).map((id) => `set /connections/${connIndex}/${field} to verified node id "${id}"`),
|
|
823
|
+
});
|
|
824
|
+
problems.push(message);
|
|
825
|
+
problems.push(`Connection "${conn.label || conn[field]}" endpoint "${conn[field]}" does not name a declared component.`);
|
|
826
|
+
}
|
|
501
827
|
if (components.has(conn.from) && components.has(conn.to)) {
|
|
502
828
|
const routed = pathFor(conn);
|
|
829
|
+
const outsidePoints = routed.points.filter(([x, y]) => x < 0 || y < 0 || x > viewBox[0] || y > viewBox[1]);
|
|
830
|
+
if (arch.meta?.quality_profile === 'showcase' && outsidePoints.length) {
|
|
831
|
+
const message = `Connection "${conn.id || `${conn.from}->${conn.to}`}" extends outside the viewBox — move the measured outside route points inward or enlarge an authored viewBox for right/bottom overflow.`;
|
|
832
|
+
diagnostics.push({
|
|
833
|
+
code: 'layout/route-out-of-bounds', severity: 'error', message,
|
|
834
|
+
subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
|
|
835
|
+
evidence: { viewBox: [...viewBox], outsidePoints, points: routed.points },
|
|
836
|
+
supportedFixes: ['move negative route coordinates inside the canvas; for right/bottom overflow, enlarge meta.viewBox or reroute inward; preserve endpoints, direction and labels, then revalidate'],
|
|
837
|
+
});
|
|
838
|
+
problems.push(message);
|
|
839
|
+
}
|
|
503
840
|
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
|
504
841
|
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
505
|
-
if (distance < 24
|
|
842
|
+
if (distance < 24 && conn.from === conn.to) {
|
|
843
|
+
// "Move the components apart" cannot be executed for a self-loop; the
|
|
844
|
+
// ports sit on one component and the sides decide how far apart.
|
|
845
|
+
const message = `Self-loop "${conn.id || conn.label || conn.from}" on component "${conn.from}" has its two ports only ${Math.round(distance)}px apart (minimum 24px) — remove fromSide/toSide so the renderer can choose the loop's sides, or set fromSide and toSide to different sides.`;
|
|
846
|
+
diagnostics.push({
|
|
847
|
+
code: 'layout/self-loop-ports', severity: 'error', message,
|
|
848
|
+
subject: { diagramType: 'architecture', collection: 'connections', index: asArray(arch.connections).indexOf(conn), ...(conn.id ? { id: conn.id } : {}), from: conn.from, to: conn.to },
|
|
849
|
+
evidence: { distancePx: Math.round(distance), minimumPx: 24, fromSide: connectionEndpointSide(conn, 'source'), toSide: connectionEndpointSide(conn, 'target'), points: routed.points },
|
|
850
|
+
supportedFixes: ['remove fromSide/toSide from the self-loop', 'set fromSide and toSide to different sides of the component'],
|
|
851
|
+
});
|
|
852
|
+
problems.push(message);
|
|
853
|
+
} else if (distance < 24) {
|
|
854
|
+
problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) — place its components farther apart.`);
|
|
855
|
+
}
|
|
506
856
|
}
|
|
507
857
|
}
|
|
508
858
|
|
|
@@ -532,6 +882,12 @@ function validateArchitecture() {
|
|
|
532
882
|
diagramType: 'architecture',
|
|
533
883
|
relationCollection: 'connections',
|
|
534
884
|
profile: arch.meta?.quality_profile,
|
|
885
|
+
// Automatic architecture routes render with an opaque crossover halo.
|
|
886
|
+
// That makes a proper X visually unambiguous while explicit authored
|
|
887
|
+
// crossings remain a blocking composition error.
|
|
888
|
+
crossingResolved: (left, right) => (
|
|
889
|
+
hasAutomaticRouteGeometry(left) && hasAutomaticRouteGeometry(right)
|
|
890
|
+
),
|
|
535
891
|
routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
|
|
536
892
|
}));
|
|
537
893
|
problems.push(...cleanAmbiguousCorridorProblems({
|
|
@@ -541,8 +897,29 @@ function validateArchitecture() {
|
|
|
541
897
|
diagramType: 'architecture',
|
|
542
898
|
relationCollection: 'connections',
|
|
543
899
|
profile: arch.meta?.quality_profile,
|
|
544
|
-
|
|
900
|
+
includeSharedEndpoints: (left, right) => hasAutomaticRouteGeometry(left) && !left.labelAt
|
|
901
|
+
&& hasAutomaticRouteGeometry(right) && !right.labelAt,
|
|
902
|
+
routeHint: 'adjust route/via or fromSide/toSide so distinct connections do not visually merge'
|
|
545
903
|
}));
|
|
904
|
+
if ((process.env.ARCHIFY_QUALITY_PROFILE || arch.meta?.quality_profile) === 'showcase') {
|
|
905
|
+
const collisions = collectArrowheadCollisions({
|
|
906
|
+
routedRelations: asArray(arch.connections)
|
|
907
|
+
.filter((conn) => components.has(conn.from) && components.has(conn.to) && hasAutomaticRouteGeometry(conn) && !conn.labelAt)
|
|
908
|
+
.map((relation) => ({ relation, points: pathFor(relation).points })),
|
|
909
|
+
});
|
|
910
|
+
for (const hit of collisions) {
|
|
911
|
+
const left = hit.left.relation;
|
|
912
|
+
const right = hit.right.relation;
|
|
913
|
+
const message = `[composition/arrowhead-collision] automatic connections "${left.id || left.from}" and "${right.id || right.from}" into "${left.to}" have arrowheads ${hit.distance}px apart (minimum ${hit.minimum}px) — enlarge or reposition the destination, or choose separate toSide ports.`;
|
|
914
|
+
problems.push(message);
|
|
915
|
+
diagnostics.push({
|
|
916
|
+
code: 'composition/arrowhead-collision', severity: 'error', message,
|
|
917
|
+
subject: { diagramType: 'architecture', collection: 'connections', id: left.id, from: left.from, to: left.to },
|
|
918
|
+
evidence: { otherId: right.id, distancePx: hit.distance, minimumPx: hit.minimum, endpoints: [hit.left.tip, hit.right.tip] },
|
|
919
|
+
supportedFixes: ['enlarge or reposition the destination', 'choose separate toSide ports'],
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
546
923
|
problems.push(...cleanBorderRunProblems({
|
|
547
924
|
relations: arch.connections,
|
|
548
925
|
endpointIds: new Set(components.keys()),
|
|
@@ -562,26 +939,47 @@ function validateArchitecture() {
|
|
|
562
939
|
profile: arch.meta?.quality_profile,
|
|
563
940
|
routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
|
|
564
941
|
}));
|
|
565
|
-
|
|
942
|
+
problems.push(...cleanRouteDetourProblems({
|
|
943
|
+
relations: arch.connections,
|
|
944
|
+
obstacles: components.values(),
|
|
945
|
+
contentRects: [...components.values(), ...boundaries],
|
|
946
|
+
endpointIds: new Set(components.keys()),
|
|
947
|
+
pathFor,
|
|
948
|
+
fromSideFor: (conn) => connectionEndpointSide(conn, 'source'),
|
|
949
|
+
toSideFor: (conn) => connectionEndpointSide(conn, 'target'),
|
|
950
|
+
diagramType: 'architecture',
|
|
951
|
+
relationCollection: 'connections',
|
|
952
|
+
profile: arch.meta?.quality_profile,
|
|
953
|
+
}));
|
|
566
954
|
// Connection labels must not land on top of components.
|
|
567
|
-
const labelRects =
|
|
568
|
-
for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
|
|
569
|
-
if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
|
|
570
|
-
const [lx, ly] = labelPoint(conn, pathFor(conn).points);
|
|
571
|
-
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
|
|
572
|
-
labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
|
|
573
|
-
}
|
|
955
|
+
const labelRects = connectionLabels;
|
|
574
956
|
for (const rect of labelRects) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
957
|
+
const blockedComponents = [...components.values()].filter(c => rectsOverlap(rect, c, -2));
|
|
958
|
+
const labelPinned = ['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => rect.relation[key] !== undefined);
|
|
959
|
+
const points = pathFor(rect.relation).points;
|
|
960
|
+
const shortHorizontalGap = points.length === 2 && Math.abs(points[0][1] - points[1][1]) < 0.0001
|
|
961
|
+
? Math.abs(points[1][0] - points[0][0]) : null;
|
|
962
|
+
const requiredGap = Math.ceil(rect.width + 16);
|
|
963
|
+
if (arch.meta?.quality_profile === 'showcase' && !labelPinned && blockedComponents.length
|
|
964
|
+
&& shortHorizontalGap != null && shortHorizontalGap < requiredGap) {
|
|
965
|
+
const message = `Label "${rect.label}" has only ${Math.round(shortHorizontalGap)}px between "${rect.relation.from}" and "${rect.relation.to}"; it needs at least ${requiredGap}px to stay beside its route — increase that clear gap or place the connected nodes on another readable row, preserving the label.`;
|
|
966
|
+
problems.push(message);
|
|
967
|
+
diagnostics.push({
|
|
968
|
+
code: 'composition/label-gap', severity: 'error', message,
|
|
969
|
+
subject: { diagramType: 'architecture', collection: 'connections', id: rect.relation.id, from: rect.relation.from, to: rect.relation.to },
|
|
970
|
+
evidence: { clearGapPx: shortHorizontalGap, minimumGapPx: requiredGap, labelWidthPx: rect.width, obstacles: blockedComponents.map(c => c.id) },
|
|
971
|
+
supportedFixes: [`increase the clear gap between the connected nodes to at least ${requiredGap}px`, 'reposition the connected nodes together while preserving the full relationship label'],
|
|
972
|
+
});
|
|
973
|
+
} else {
|
|
974
|
+
for (const c of blockedComponents) {
|
|
975
|
+
problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c, 'component', viewBox, components.values())}`);
|
|
578
976
|
}
|
|
579
977
|
}
|
|
580
978
|
if (enforcesBoundaryTitleComposition) {
|
|
581
979
|
for (const boundary of boundaries) {
|
|
582
980
|
if (!rectsOverlap(boundary.title, rect)) continue;
|
|
583
981
|
problems.push(
|
|
584
|
-
`Boundary label "${boundary.label}" overlaps connection label "${rect.label}"
|
|
982
|
+
`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
983
|
);
|
|
586
984
|
}
|
|
587
985
|
}
|
|
@@ -595,29 +993,34 @@ function validateArchitecture() {
|
|
|
595
993
|
relationCollection: 'connections',
|
|
596
994
|
profile: arch.meta?.quality_profile,
|
|
597
995
|
}));
|
|
996
|
+
// See collectLabelCanvasOverflow in shared/geometry.mjs. An auto canvas now
|
|
997
|
+
// covers these rects, so this reports authored viewBoxes and the origin side,
|
|
998
|
+
// which growth cannot reach.
|
|
999
|
+
problems.push(...cleanLabelCanvasContainmentProblems({
|
|
1000
|
+
labels: labelRects,
|
|
1001
|
+
viewBox,
|
|
1002
|
+
diagramType: 'architecture',
|
|
1003
|
+
relationCollection: 'connections',
|
|
1004
|
+
profile: arch.meta?.quality_profile,
|
|
1005
|
+
}));
|
|
598
1006
|
|
|
599
1007
|
if (problems.length) {
|
|
600
1008
|
throwDiagnosticProblems('Architecture layout validation failed', problems, {
|
|
601
1009
|
subject: { diagramType: 'architecture' },
|
|
1010
|
+
diagnostics,
|
|
602
1011
|
});
|
|
603
1012
|
}
|
|
604
1013
|
}
|
|
605
1014
|
|
|
606
1015
|
function buildLayoutReport() {
|
|
607
|
-
const labels =
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
y: Math.round(ly - 10),
|
|
616
|
-
width: Math.round(w),
|
|
617
|
-
height: 14,
|
|
618
|
-
labelAt: [Math.round(lx), Math.round(ly)],
|
|
619
|
-
});
|
|
620
|
-
}
|
|
1016
|
+
const labels = connectionLabels.map((rect) => ({
|
|
1017
|
+
text: rect.label,
|
|
1018
|
+
x: Math.round(rect.x),
|
|
1019
|
+
y: Math.round(rect.y),
|
|
1020
|
+
width: Math.round(rect.width),
|
|
1021
|
+
height: 14,
|
|
1022
|
+
labelAt: [Math.round(rect.lx), Math.round(rect.ly)],
|
|
1023
|
+
}));
|
|
621
1024
|
return {
|
|
622
1025
|
ok: true,
|
|
623
1026
|
diagram_type: 'architecture',
|
|
@@ -629,323 +1032,13 @@ function buildLayoutReport() {
|
|
|
629
1032
|
.filter((conn) => components.has(conn.from) && components.has(conn.to))
|
|
630
1033
|
.map((conn) => {
|
|
631
1034
|
const routed = pathFor(conn);
|
|
632
|
-
const labelAt = conn.label ? labelPoint(conn, routed.points) : null;
|
|
1035
|
+
const labelAt = conn.label ? resolvedLabelPoints.get(conn) || labelPoint(conn, routed.points) : null;
|
|
633
1036
|
return connectionPath(conn, routed, labelAt);
|
|
634
1037
|
}),
|
|
635
1038
|
labels,
|
|
636
1039
|
};
|
|
637
1040
|
}
|
|
638
1041
|
|
|
639
|
-
// ---- Connection routing ------------------------------------------------------
|
|
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) {
|
|
812
|
-
if (conn.via) return conn.via;
|
|
813
|
-
switch (conn.route || 'auto') {
|
|
814
|
-
case 'straight':
|
|
815
|
-
return [];
|
|
816
|
-
case 'orthogonal-h': {
|
|
817
|
-
const midX = (start[0] + end[0]) / 2;
|
|
818
|
-
return [[midX, start[1]], [midX, end[1]]];
|
|
819
|
-
}
|
|
820
|
-
case 'orthogonal-v': {
|
|
821
|
-
const midY = (start[1] + end[1]) / 2;
|
|
822
|
-
return [[start[0], midY], [end[0], midY]];
|
|
823
|
-
}
|
|
824
|
-
case 'auto':
|
|
825
|
-
default: {
|
|
826
|
-
// Direct line unless the anchors are clearly orthogonal-friendly.
|
|
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
|
-
|
|
872
|
-
const midX = (start[0] + end[0]) / 2;
|
|
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;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
|
|
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
|
-
|
|
925
|
-
function pathFor(conn) {
|
|
926
|
-
if (pathCache.has(conn)) return pathCache.get(conn);
|
|
927
|
-
const from = components.get(conn.from);
|
|
928
|
-
const to = components.get(conn.to);
|
|
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];
|
|
944
|
-
const routed = { d: roundedPath(points, 8), points };
|
|
945
|
-
pathCache.set(conn, routed);
|
|
946
|
-
return routed;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
1042
|
// ---- Rendering ---------------------------------------------------------------
|
|
950
1043
|
function renderBoundaryFrame(b, index) {
|
|
951
1044
|
const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
|
|
@@ -965,16 +1058,25 @@ function renderConnectionPath(conn, index) {
|
|
|
965
1058
|
const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
|
|
966
1059
|
const routed = pathFor(conn);
|
|
967
1060
|
const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
|
|
968
|
-
|
|
1061
|
+
const automaticRoute = hasAutomaticRouteGeometry(conn);
|
|
1062
|
+
const underlay = automaticRoute
|
|
1063
|
+
? ` <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>\n`
|
|
1064
|
+
: '';
|
|
1065
|
+
const crossover = automaticRoute
|
|
1066
|
+
? ` data-composition-crossover="halo"${conn.labelAt ? '' : ' data-composition-independent="true"'}` : '';
|
|
1067
|
+
const edge = ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}"${crossover}${authoredStraightRouteAttrs(conn, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
1068
|
+
if (!automaticRoute) return edge;
|
|
1069
|
+
// The wrapper is presentation-only: viewer state remains on the one semantic
|
|
1070
|
+
// edge, while CSS can keep its preceding mask underlay at the same opacity.
|
|
1071
|
+
return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
|
|
969
1072
|
}
|
|
970
1073
|
|
|
971
1074
|
function renderConnectionLabel(conn, index) {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
|
|
1075
|
+
const box = connectionLabelBox(conn);
|
|
1076
|
+
if (!box) return '';
|
|
975
1077
|
return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
|
|
976
|
-
<rect x="${
|
|
977
|
-
<text x="${lx}" y="${ly}" class="${
|
|
1078
|
+
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3" class="c-mask"/>
|
|
1079
|
+
<text x="${box.lx}" y="${box.ly}" class="${edgeLabelAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
|
|
978
1080
|
</g>`;
|
|
979
1081
|
}
|
|
980
1082
|
|
|
@@ -995,9 +1097,9 @@ function renderComponent(c) {
|
|
|
995
1097
|
const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c), ...brandMetadataFor(c) };
|
|
996
1098
|
return ` <g ${focusNodeAttrs(c.id, c.label, passport, arch.meta.locale)}>
|
|
997
1099
|
${focusNodeTitle(c.label, passport)}
|
|
998
|
-
<rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="
|
|
999
|
-
<rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="
|
|
1000
|
-
${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
|
|
1100
|
+
<rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="8" class="c-mask"/>
|
|
1101
|
+
<rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="8" class="${fill}"${animateAttr(arch.meta, 'node', componentSteps.get(c.id))} stroke-width="1.5"/>
|
|
1102
|
+
${renderSemanticSigil(c.type, { icon: c.icon, x: c.x + 6, y: c.y + 6 })}${brand ? `\n ${brand}` : ''}
|
|
1001
1103
|
<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
1104
|
</g>`;
|
|
1003
1105
|
}
|
|
@@ -1006,18 +1108,15 @@ function renderLegend() {
|
|
|
1006
1108
|
const entries = architectureLegendEntries;
|
|
1007
1109
|
const relationshipObstacles = relationshipLegendObstacles(arch.connections, {
|
|
1008
1110
|
pointsFor: (connection) => pathFor(connection).points,
|
|
1009
|
-
labelRectFor:
|
|
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
|
-
},
|
|
1111
|
+
labelRectFor: connectionLabelBox,
|
|
1015
1112
|
});
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
)
|
|
1113
|
+
let contentBottom = 0;
|
|
1114
|
+
for (const component of components.values()) {
|
|
1115
|
+
contentBottom = Math.max(contentBottom, component.y + component.height);
|
|
1116
|
+
}
|
|
1117
|
+
for (const boundary of boundaries) {
|
|
1118
|
+
contentBottom = Math.max(contentBottom, boundary.y + boundary.height);
|
|
1119
|
+
}
|
|
1021
1120
|
return renderResolvedLegend({
|
|
1022
1121
|
entries,
|
|
1023
1122
|
locale: arch.meta.locale,
|
|
@@ -1035,9 +1134,23 @@ function renderLegend() {
|
|
|
1035
1134
|
}
|
|
1036
1135
|
|
|
1037
1136
|
function renderSvg() {
|
|
1038
|
-
|
|
1137
|
+
// An automatic architecture canvas is compiler-measured geometry. Let the
|
|
1138
|
+
// Reader spend the real desktop height budget on it, including when an
|
|
1139
|
+
// outer route makes the canvas taller than the ordinary wide-diagram
|
|
1140
|
+
// threshold. Authored viewBoxes keep their geometry and existing Reader width policy;
|
|
1141
|
+
// their declared height may use readable document scrolling without reflow.
|
|
1142
|
+
const readerFit = arch.meta?.viewBox
|
|
1143
|
+
? ' data-diagram-type="architecture" data-reader-fit="authored-height"'
|
|
1144
|
+
: ' data-reader-fit="intrinsic-height"';
|
|
1145
|
+
// A complete repository architecture is allowed to use normal page scroll;
|
|
1146
|
+
// keep its common-desktop text at a comfortable reading size instead of
|
|
1147
|
+
// shrinking a semantically rich graph to the universal emergency floor.
|
|
1148
|
+
const readerMinimumText = arch.meta?.viewBox ? '' : ' data-reader-min-text="7.5"';
|
|
1149
|
+
const readerPrimaryText = arch.meta?.viewBox ? '' : ' data-reader-primary-text="14"';
|
|
1150
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta)} ${NODE_CARD_ATTR}${readerFit}${readerMinimumText}${readerPrimaryText}>
|
|
1039
1151
|
${svgAccessibleText(arch.meta, 'architecture')}
|
|
1040
1152
|
${renderDefinitions()}
|
|
1153
|
+
${nodeCardStyle()}
|
|
1041
1154
|
|
|
1042
1155
|
<!-- Background Grid -->
|
|
1043
1156
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
@@ -1062,17 +1175,30 @@ ${renderLegend()}
|
|
|
1062
1175
|
</svg>`;
|
|
1063
1176
|
}
|
|
1064
1177
|
|
|
1065
|
-
validateArchitecture();
|
|
1066
1178
|
if (layoutJsonMode) {
|
|
1067
|
-
|
|
1068
|
-
|
|
1179
|
+
try {
|
|
1180
|
+
validateArchitecture();
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
// A rejected layout is still useful repair evidence. Input/implementation
|
|
1183
|
+
// failures must retain their existing failure boundary, not partial geometry.
|
|
1184
|
+
if (!error.archifyDiagnostics?.length) throw error;
|
|
1185
|
+
console.log(JSON.stringify({
|
|
1186
|
+
...buildLayoutReport(),
|
|
1187
|
+
...rendererFailure(error),
|
|
1188
|
+
contract: 'archify-architecture-layout-v1',
|
|
1189
|
+
}, null, 2));
|
|
1190
|
+
process.exitCode = 1;
|
|
1191
|
+
}
|
|
1192
|
+
if (!process.exitCode) console.log(JSON.stringify(buildLayoutReport(), null, 2));
|
|
1193
|
+
} else {
|
|
1194
|
+
validateArchitecture();
|
|
1195
|
+
writeDiagram({
|
|
1196
|
+
outPath,
|
|
1197
|
+
template,
|
|
1198
|
+
diagramType: 'architecture',
|
|
1199
|
+
meta: arch.meta,
|
|
1200
|
+
svg: renderSvg(),
|
|
1201
|
+
cards: arch.cards,
|
|
1202
|
+
sourceEvidence,
|
|
1203
|
+
});
|
|
1069
1204
|
}
|
|
1070
|
-
writeDiagram({
|
|
1071
|
-
outPath,
|
|
1072
|
-
template,
|
|
1073
|
-
diagramType: 'architecture',
|
|
1074
|
-
meta: arch.meta,
|
|
1075
|
-
svg: renderSvg(),
|
|
1076
|
-
cards: arch.cards,
|
|
1077
|
-
sourceEvidence,
|
|
1078
|
-
});
|