@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
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import {
|
|
5
|
+
animateAttr,
|
|
6
|
+
focusEdgeAttrs,
|
|
7
|
+
focusNodeAttrs,
|
|
8
|
+
focusNodeTitle,
|
|
9
|
+
loadDiagram,
|
|
10
|
+
svgAccessibleText,
|
|
11
|
+
svgRootAttrs,
|
|
12
|
+
writeDiagram,
|
|
13
|
+
} from '../shared/cli.mjs';
|
|
14
|
+
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
15
|
+
import {
|
|
16
|
+
legendFootprint,
|
|
17
|
+
measureLegend,
|
|
18
|
+
relationshipLegendObstacles,
|
|
19
|
+
resolveLegend,
|
|
20
|
+
renderLegend as renderResolvedLegend,
|
|
21
|
+
} from '../shared/legend.mjs';
|
|
22
|
+
import { nodeTextFit } from '../shared/text-fit.mjs';
|
|
23
|
+
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
24
|
+
import { bandedLayout, resolveEntityPos } from '../erd/grid.mjs';
|
|
25
|
+
import {
|
|
26
|
+
asArray,
|
|
27
|
+
cleanAmbiguousCorridorProblems,
|
|
28
|
+
cleanCrossingProblems,
|
|
29
|
+
cleanEndpointSideProblems,
|
|
30
|
+
cleanFlowProblems,
|
|
31
|
+
cleanLabelRouteClearanceProblems,
|
|
32
|
+
cleanRouteRhythmProblems,
|
|
33
|
+
collectAmbiguousCorridors,
|
|
34
|
+
legacyDefaultFromSide,
|
|
35
|
+
legacyDefaultToSide,
|
|
36
|
+
properSegmentIntersection,
|
|
37
|
+
rectsOverlap,
|
|
38
|
+
roundedPath,
|
|
39
|
+
routePointsValue,
|
|
40
|
+
segmentIntersectsRect,
|
|
41
|
+
suggestComponentSeparation,
|
|
42
|
+
suggestLabelObstacleFix,
|
|
43
|
+
} from '../shared/geometry.mjs';
|
|
44
|
+
import { createRouter } from '../architecture/routing.mjs';
|
|
45
|
+
import { placeAutomaticLabels } from '../shared/automatic-labels.mjs';
|
|
46
|
+
import { connectionPath as relationshipPath } from '../shared/layout-report.mjs';
|
|
47
|
+
|
|
48
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const layoutJsonMode = process.argv.includes('--layout-json');
|
|
50
|
+
const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
|
|
51
|
+
const { diagram: cd, template, outPath, sourceEvidence } = loadDiagram({
|
|
52
|
+
rendererDir: __dirname,
|
|
53
|
+
diagramType: 'class',
|
|
54
|
+
defaultExample: 'payments.class.json',
|
|
55
|
+
argv: cliArgs,
|
|
56
|
+
});
|
|
57
|
+
const locale = cd.meta.locale;
|
|
58
|
+
|
|
59
|
+
// The grid is always on: a type without `pos` is placed by its row/col cell.
|
|
60
|
+
// Gaps are wider than the ERD's because a relationship here carries a marker
|
|
61
|
+
// at one end and usually a label, and both need a corridor to sit in.
|
|
62
|
+
const grid = {
|
|
63
|
+
origin: [32, 40],
|
|
64
|
+
gapX: 128,
|
|
65
|
+
gapY: 84,
|
|
66
|
+
typeW: 180,
|
|
67
|
+
typeMaxW: 300,
|
|
68
|
+
...(cd.layout || {}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const layout = {
|
|
72
|
+
margin: 24,
|
|
73
|
+
legendH: 34,
|
|
74
|
+
padX: 12,
|
|
75
|
+
headerH: 44,
|
|
76
|
+
headerPlainH: 34,
|
|
77
|
+
nameFont: 13.5,
|
|
78
|
+
stereotypeFont: 9.5,
|
|
79
|
+
memberFont: 11,
|
|
80
|
+
rowH: 20,
|
|
81
|
+
compartmentPad: 5,
|
|
82
|
+
minRelationshipLength: 28,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ---- Type kinds --------------------------------------------------------------
|
|
86
|
+
// The kind owns the box's colour family and its header keyword. A plain class
|
|
87
|
+
// carries no keyword, which is the UML default; every other kind states it, so
|
|
88
|
+
// an interface can never be read as a class that merely has no fields.
|
|
89
|
+
const KIND_TONE = {
|
|
90
|
+
class: 'backend',
|
|
91
|
+
abstract: 'backend',
|
|
92
|
+
interface: 'frontend',
|
|
93
|
+
enum: 'messagebus',
|
|
94
|
+
record: 'database',
|
|
95
|
+
};
|
|
96
|
+
const STEREOTYPE = {
|
|
97
|
+
abstract: '«abstract»',
|
|
98
|
+
interface: '«interface»',
|
|
99
|
+
enum: '«enumeration»',
|
|
100
|
+
record: '«record»',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const VISIBILITY_GLYPH = { public: '+', protected: '#', package: '~', private: '-' };
|
|
104
|
+
|
|
105
|
+
function visibilityPrefix(member) {
|
|
106
|
+
return member.visibility ? `${VISIBILITY_GLYPH[member.visibility]} ` : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function attributeText(attribute) {
|
|
110
|
+
return `${visibilityPrefix(attribute)}${attribute.name}${attribute.type ? `: ${attribute.type}` : ''}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function methodText(method) {
|
|
114
|
+
return `${visibilityPrefix(method)}${method.name}(${method.parameters ?? ''})${method.returns ? `: ${method.returns}` : ''}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Member rows are set in a monospace face, whose ASCII advance is the shared
|
|
118
|
+
// 0.6em estimate; wide glyphs count as two units, so the measurement never
|
|
119
|
+
// under-reserves space and a member is never clipped by its own box.
|
|
120
|
+
const MEMBER_ADVANCE = layout.memberFont * nodeTextFit.widthFactor;
|
|
121
|
+
const CONTINUATION_INDENT = 2;
|
|
122
|
+
// The visibility chip is wider than the two-character `+ ` prefix the member
|
|
123
|
+
// text is measured with; every box reserves the difference.
|
|
124
|
+
const VIS_CHIP = 14;
|
|
125
|
+
const VIS_GAP = 6;
|
|
126
|
+
const VIS_EXTRA = Math.ceil(VIS_CHIP + VIS_GAP - 2 * MEMBER_ADVANCE);
|
|
127
|
+
|
|
128
|
+
function nameWidth(type) {
|
|
129
|
+
// Bold display text runs wider than the regular estimate.
|
|
130
|
+
return textUnits(type.label) * layout.nameFont * nodeTextFit.widthFactor * 1.08;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function memberRows(type) {
|
|
134
|
+
return [
|
|
135
|
+
{ kind: 'attributes', rows: asArray(type.attributes).map((attribute) => ({ member: attribute, text: attributeText(attribute) })) },
|
|
136
|
+
{ kind: 'methods', rows: asArray(type.methods).map((method) => ({ member: method, text: methodText(method) })) },
|
|
137
|
+
].filter((compartment) => compartment.rows.length);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A member longer than its box wraps instead of being cut: breaks fall after
|
|
141
|
+
// an opening parenthesis or a parameter comma, or before the return type, and
|
|
142
|
+
// continuation lines are indented so they read as one signature. A method
|
|
143
|
+
// that wraps is set the way a formatter sets it: the open parenthesis ends the
|
|
144
|
+
// first line, parameters hang one step deeper, and `): Return` closes it, so
|
|
145
|
+
// every wrapped signature in a diagram has the same shape. A single token
|
|
146
|
+
// longer than a whole line is split by character, never dropped.
|
|
147
|
+
const PARAMETER_INDENT = 4;
|
|
148
|
+
|
|
149
|
+
function fillLines(pieces, limit, firstIndent, indent) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
let current = '';
|
|
152
|
+
const room = () => limit - (lines.length ? indent : firstIndent);
|
|
153
|
+
const push = (text) => lines.push({ text, indent: lines.length ? indent : firstIndent });
|
|
154
|
+
for (const piece of pieces) {
|
|
155
|
+
if (textUnits(current + piece) <= room()) {
|
|
156
|
+
current += piece;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (current) push(current.trimEnd());
|
|
160
|
+
current = piece.trimStart();
|
|
161
|
+
while (textUnits(current) > room()) {
|
|
162
|
+
const chars = Array.from(current);
|
|
163
|
+
let cut = 0;
|
|
164
|
+
for (let units = 0; cut < chars.length && units + textUnits(chars[cut]) <= room(); cut += 1) units += textUnits(chars[cut]);
|
|
165
|
+
push(chars.slice(0, Math.max(1, cut)).join(''));
|
|
166
|
+
current = chars.slice(Math.max(1, cut)).join('');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (current) push(current.trimEnd());
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function wrapMember(text, availablePx) {
|
|
174
|
+
const limit = Math.max(8, Math.floor(availablePx / MEMBER_ADVANCE));
|
|
175
|
+
if (textUnits(text) <= limit) return [{ text, indent: 0 }];
|
|
176
|
+
const lines = fillLines(text.split(/(?<=\(|, )|(?=\): )/), limit, 0, CONTINUATION_INDENT);
|
|
177
|
+
const open = text.indexOf('(');
|
|
178
|
+
const close = text.lastIndexOf(')');
|
|
179
|
+
if (open === -1 || close < open) return lines;
|
|
180
|
+
const head = text.slice(0, open + 1);
|
|
181
|
+
const parameters = text.slice(open + 1, close).split(/(?<=, )/);
|
|
182
|
+
const tail = text.slice(close);
|
|
183
|
+
return [
|
|
184
|
+
...fillLines([head], limit, 0, CONTINUATION_INDENT),
|
|
185
|
+
...fillLines(parameters, limit, PARAMETER_INDENT, PARAMETER_INDENT),
|
|
186
|
+
...fillLines([tail], limit, CONTINUATION_INDENT, CONTINUATION_INDENT),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function headerHeight(type) {
|
|
191
|
+
return STEREOTYPE[type.kind] ? layout.headerH : layout.headerPlainH;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Header badge (a lettered kind marker) reserves room on the left of the name.
|
|
195
|
+
const BADGE_SIZE = 16;
|
|
196
|
+
const BADGE_PORCH = BADGE_SIZE + 8;
|
|
197
|
+
|
|
198
|
+
function headerWidth(type) {
|
|
199
|
+
return Math.ceil(layout.padX * 2 + BADGE_PORCH * 2 + Math.max(
|
|
200
|
+
nameWidth(type),
|
|
201
|
+
STEREOTYPE[type.kind] ? textUnits(STEREOTYPE[type.kind]) * layout.stereotypeFont * nodeTextFit.widthFactor : 0,
|
|
202
|
+
));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// An authored width is authoritative. An omitted one grows to the widest
|
|
206
|
+
// member up to `layout.typeMaxW`; past that, members wrap. The title never
|
|
207
|
+
// wraps, so a box is always at least as wide as its header needs.
|
|
208
|
+
function resolvedWidth(type) {
|
|
209
|
+
if (Number.isFinite(type.width)) return type.width;
|
|
210
|
+
const members = memberRows(type).flatMap((compartment) => compartment.rows)
|
|
211
|
+
.reduce((max, row) => Math.max(max, textUnits(row.text) * MEMBER_ADVANCE), 0);
|
|
212
|
+
const wanted = Math.ceil(members + layout.padX * 2 + VIS_EXTRA);
|
|
213
|
+
return Math.max(headerWidth(type), Math.min(grid.typeMaxW, Math.max(grid.typeW, wanted)));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function compartments(type) {
|
|
217
|
+
const available = type.width - layout.padX * 2 - VIS_EXTRA;
|
|
218
|
+
return memberRows(type).map((compartment) => ({
|
|
219
|
+
...compartment,
|
|
220
|
+
rows: compartment.rows.map((row) => ({ ...row, lines: wrapMember(row.text, available) })),
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function typeHeight(type) {
|
|
225
|
+
return headerHeight(type) + compartments(type).reduce((sum, compartment) => sum
|
|
226
|
+
+ compartment.rows.reduce((lines, row) => lines + row.lines.length, 0) * layout.rowH
|
|
227
|
+
+ layout.compartmentPad * 2, 0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const measuredTypes = asArray(cd.types).map((type) => {
|
|
231
|
+
const width = resolvedWidth(type);
|
|
232
|
+
return { ...type, width, height: typeHeight({ ...type, width }) };
|
|
233
|
+
});
|
|
234
|
+
const bands = bandedLayout(measuredTypes, { ...grid, entityW: grid.typeW });
|
|
235
|
+
// Types are centred in their row band, so a relationship between two types of
|
|
236
|
+
// one row runs straight across instead of jogging between their centres.
|
|
237
|
+
const types = new Map(measuredTypes.map((type) => {
|
|
238
|
+
const [x, top] = resolveEntityPos(type, { ...grid, entityW: grid.typeW }, bands);
|
|
239
|
+
const y = placedByGrid(type) ? top + ((bands.heights.get(type.row) ?? type.height) - type.height) / 2 : top;
|
|
240
|
+
return [type.id, { ...type, x, y, cx: x + type.width / 2, cy: y + type.height / 2 }];
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
// Routing runs at module load, so a type without a position is reported before
|
|
244
|
+
// any geometry is computed from it.
|
|
245
|
+
{
|
|
246
|
+
const unplaced = [...types.values()].filter((type) => !Number.isFinite(type.x) || !Number.isFinite(type.y));
|
|
247
|
+
if (unplaced.length) {
|
|
248
|
+
throwDiagnosticProblems('Class diagram placement failed',
|
|
249
|
+
unplaced.map((type) => `Type "${type.id}" needs grid row/col or an absolute pos [x,y].`),
|
|
250
|
+
{ code: 'layout/constraint', subject: { diagramType: 'class' } });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const relationships = asArray(cd.relationships).map((relationship) => {
|
|
255
|
+
if (!Array.isArray(relationship.via) || relationship.via.length) return relationship;
|
|
256
|
+
// 空路径与省略字段等价,统一主干和普通路由的判断,同时保留作者原始文档。
|
|
257
|
+
const automatic = { ...relationship };
|
|
258
|
+
delete automatic.via;
|
|
259
|
+
return automatic;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const typeSteps = new Map();
|
|
263
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
264
|
+
if (!typeSteps.has(relationship.to)) typeSteps.set(relationship.to, index);
|
|
265
|
+
if (!typeSteps.has(relationship.from)) typeSteps.set(relationship.from, index + 1);
|
|
266
|
+
}
|
|
267
|
+
for (const [index, type] of asArray(cd.types).entries()) {
|
|
268
|
+
if (!typeSteps.has(type.id)) typeSteps.set(type.id, index);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- Relationship notation -----------------------------------------------------
|
|
272
|
+
// One notation table owns the line dash, the marker at each end, and the legend
|
|
273
|
+
// swatch, so the legend can never teach a different symbol than the lines draw.
|
|
274
|
+
//
|
|
275
|
+
// Every relationship reads `from` -> `to`:
|
|
276
|
+
// - dependency: `from` uses `to` dashed, open arrow at `to`
|
|
277
|
+
// - association: `from` holds a `to` solid, open arrow at `to`
|
|
278
|
+
// - inheritance: `from` extends `to` solid, hollow triangle at `to`
|
|
279
|
+
// - realization: `from` implements `to` dashed, hollow triangle at `to`
|
|
280
|
+
// - composition: `from` owns `to` (lifetime) solid, filled diamond at `from`
|
|
281
|
+
// - aggregation: `from` groups `to` solid, hollow diamond at `from`
|
|
282
|
+
const NOTATION = {
|
|
283
|
+
dependency: { dashed: true, end: 'open' },
|
|
284
|
+
association: { dashed: false, end: 'open' },
|
|
285
|
+
inheritance: { dashed: false, end: 'triangle' },
|
|
286
|
+
realization: { dashed: true, end: 'triangle' },
|
|
287
|
+
composition: { dashed: false, start: 'diamond-filled' },
|
|
288
|
+
aggregation: { dashed: false, start: 'diamond-hollow' },
|
|
289
|
+
};
|
|
290
|
+
const RELATIONSHIP_KINDS = Object.keys(NOTATION);
|
|
291
|
+
const GENERALIZATION = new Set(['inheritance', 'realization']);
|
|
292
|
+
|
|
293
|
+
// Marker glyphs, in marker space. The path end sits on the type border at refX,
|
|
294
|
+
// so a triangle's tip and a diamond's point touch the type they describe.
|
|
295
|
+
const GLYPH = {
|
|
296
|
+
open: { width: 12, height: 12, refX: 11, refY: 6, body: '<path d="M 1 1 L 11 6 L 1 11" fill="none"/>' },
|
|
297
|
+
triangle: { width: 16, height: 16, refX: 15, refY: 8, body: '<path d="M 1 1.5 L 15 8 L 1 14.5 Z" class="cl-marker-hollow"/>' },
|
|
298
|
+
'diamond-filled': { width: 20, height: 12, refX: 1, refY: 6, body: '<path d="M 1 6 L 10 1 L 19 6 L 10 11 Z" class="cl-marker-filled"/>' },
|
|
299
|
+
'diamond-hollow': { width: 20, height: 12, refX: 1, refY: 6, body: '<path d="M 1 6 L 10 1 L 19 6 L 10 11 Z" class="cl-marker-hollow"/>' },
|
|
300
|
+
};
|
|
301
|
+
// A hierarchy bus is drawn from the supertype outward (see renderRelationshipPath),
|
|
302
|
+
// so its triangle is a start marker turned back toward the supertype.
|
|
303
|
+
GLYPH['triangle-start'] = { ...GLYPH.triangle, orient: 'auto-start-reverse' };
|
|
304
|
+
// The tallest glyph; ports on one side closer than this would draw overlapping
|
|
305
|
+
// markers that read as one shape.
|
|
306
|
+
const MARKER_HEIGHT = 16;
|
|
307
|
+
const PORT_SPACING = 26;
|
|
308
|
+
|
|
309
|
+
function renderMarkerDefs() {
|
|
310
|
+
const used = new Set();
|
|
311
|
+
for (const relationship of relationships) {
|
|
312
|
+
const notation = NOTATION[relationship.kind];
|
|
313
|
+
if (notation?.end) used.add(notation.end);
|
|
314
|
+
if (notation?.start) used.add(notation.start);
|
|
315
|
+
}
|
|
316
|
+
if (busPaths.size) used.add('triangle-start');
|
|
317
|
+
const markup = Object.entries(GLYPH).filter(([id]) => used.has(id)).map(([id, glyph]) => (
|
|
318
|
+
` <marker id="cl-${id}" markerWidth="${glyph.width}" markerHeight="${glyph.height}" refX="${glyph.refX}" refY="${glyph.refY}" orient="${glyph.orient || 'auto'}" markerUnits="userSpaceOnUse" class="cl-marker" stroke-width="1.5" stroke-linejoin="round">
|
|
319
|
+
${glyph.body}
|
|
320
|
+
</marker>`));
|
|
321
|
+
return markup.length ? `\n <!-- UML relationship markers -->\n${markup.join('\n')}` : '';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- Layout --------------------------------------------------------------------
|
|
325
|
+
function placedByGrid(type) {
|
|
326
|
+
return !(Array.isArray(type.pos) && type.pos.length === 2);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const LEGEND_CATALOG = RELATIONSHIP_KINDS
|
|
330
|
+
.map((kind) => ({ kind, label: i18nText(locale, `legend.class.${kind}`), interactive: false }));
|
|
331
|
+
const presentKinds = new Set(relationships.map((relationship) => relationship.kind));
|
|
332
|
+
const LEGEND_SWATCH = 34;
|
|
333
|
+
const LEGEND_BASELINE_GAP = 16;
|
|
334
|
+
const LEGEND_FONT_SIZE = 10;
|
|
335
|
+
const legendEntries = resolveLegend(cd.meta?.legend, LEGEND_CATALOG, presentKinds)
|
|
336
|
+
.map((entry) => ({ ...entry, swatchWidth: LEGEND_SWATCH }));
|
|
337
|
+
|
|
338
|
+
function contentBounds() {
|
|
339
|
+
const placed = [...types.values()].filter((type) => Number.isFinite(type.x) && Number.isFinite(type.y));
|
|
340
|
+
return {
|
|
341
|
+
maxX: Math.max(0, ...placed.map((type) => type.x + type.width)),
|
|
342
|
+
maxY: Math.max(0, ...placed.map((type) => type.y + type.height)),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function legendLayout(width, height, { obstacles = [], unfit = 'error' } = {}) {
|
|
347
|
+
return {
|
|
348
|
+
x: layout.margin,
|
|
349
|
+
baselineY: height - LEGEND_BASELINE_GAP,
|
|
350
|
+
width: width - layout.margin * 2,
|
|
351
|
+
fontSize: LEGEND_FONT_SIZE,
|
|
352
|
+
minTitleY: contentBounds().maxY + 8,
|
|
353
|
+
obstacles,
|
|
354
|
+
unfit,
|
|
355
|
+
diagramType: 'class',
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function autoViewBox() {
|
|
360
|
+
const { maxX, maxY } = contentBounds();
|
|
361
|
+
let width = Math.ceil(maxX + layout.margin);
|
|
362
|
+
let footprint = legendFootprint(legendEntries, { width: Math.max(1, width - layout.margin * 2) });
|
|
363
|
+
if (footprint.minWidth > width - layout.margin * 2) {
|
|
364
|
+
width = Math.ceil(footprint.minWidth + layout.margin * 2);
|
|
365
|
+
footprint = legendFootprint(legendEntries, { width: width - layout.margin * 2 });
|
|
366
|
+
}
|
|
367
|
+
let height = Math.ceil(maxY + layout.margin + (legendEntries.length ? layout.legendH + footprint.extraHeight : 0));
|
|
368
|
+
for (let attempt = 0; legendEntries.length && attempt < 4; attempt += 1) {
|
|
369
|
+
if (measureLegend(legendEntries, legendLayout(width, height, { unfit: 'hide' }))) break;
|
|
370
|
+
height += layout.legendH;
|
|
371
|
+
}
|
|
372
|
+
return [Math.max(320, width), Math.max(240, height)];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const viewBox = Array.isArray(cd.meta?.viewBox) ? cd.meta.viewBox : autoViewBox();
|
|
376
|
+
|
|
377
|
+
// ---- Routing -------------------------------------------------------------------
|
|
378
|
+
// A generalization is drawn vertically whenever the two types are not side by
|
|
379
|
+
// side: the supertype above, its subtypes below, which is how a reader expects
|
|
380
|
+
// a hierarchy to stand. Other relationships keep the shared horizontal-first
|
|
381
|
+
// inference, and stacked types always use their facing top/bottom sides.
|
|
382
|
+
function sideFor(relationship, endpoint) {
|
|
383
|
+
const from = types.get(relationship.from);
|
|
384
|
+
const to = types.get(relationship.to);
|
|
385
|
+
if (!from || !to) return undefined;
|
|
386
|
+
const overlapX = from.x < to.x + to.width && to.x < from.x + from.width;
|
|
387
|
+
const overlapY = from.y < to.y + to.height && to.y < from.y + from.height;
|
|
388
|
+
const vertical = overlapX || (!overlapY && GENERALIZATION.has(relationship.kind));
|
|
389
|
+
if (!vertical) return endpoint === 'source' ? legacyDefaultFromSide(from, to) : legacyDefaultToSide(from, to);
|
|
390
|
+
const down = to.cy > from.cy;
|
|
391
|
+
if (endpoint === 'source') return down ? 'bottom' : 'top';
|
|
392
|
+
return down ? 'top' : 'bottom';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderableRelationship(relationship) {
|
|
396
|
+
return types.has(relationship.from) && types.has(relationship.to) && relationship.from !== relationship.to;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const routable = relationships.filter(renderableRelationship);
|
|
400
|
+
|
|
401
|
+
// ---- Hierarchy bus -------------------------------------------------------------
|
|
402
|
+
// Two or more automatic generalizations of one kind into one supertype, all
|
|
403
|
+
// from subtypes placed below it, draw as one tree: a single triangle and trunk
|
|
404
|
+
// under the supertype, a horizontal bus, and a drop to each subtype. That is
|
|
405
|
+
// the conventional UML shape, and it reads as "these all implement X" instead
|
|
406
|
+
// of as N unrelated arrows. A bus that would cross another type is not drawn;
|
|
407
|
+
// its members fall back to ordinary routes.
|
|
408
|
+
const BUS_MIN_GAP = 40;
|
|
409
|
+
|
|
410
|
+
function autoRouted(relationship) {
|
|
411
|
+
return !relationship.via?.length && (!relationship.route || relationship.route === 'auto')
|
|
412
|
+
&& !relationship.fromSide && !relationship.toSide && !relationship.labelAt;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const busPaths = new Map();
|
|
416
|
+
const busGroups = new Map();
|
|
417
|
+
{
|
|
418
|
+
const groups = new Map();
|
|
419
|
+
for (const relationship of routable) {
|
|
420
|
+
if (!GENERALIZATION.has(relationship.kind) || !autoRouted(relationship)) continue;
|
|
421
|
+
const key = `${relationship.to}\u0000${relationship.kind}`;
|
|
422
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
423
|
+
groups.get(key).push(relationship);
|
|
424
|
+
}
|
|
425
|
+
for (const members of groups.values()) {
|
|
426
|
+
if (members.length < 2) continue;
|
|
427
|
+
const target = types.get(members[0].to);
|
|
428
|
+
const bottom = target.y + target.height;
|
|
429
|
+
const sources = members.map((relationship) => types.get(relationship.from));
|
|
430
|
+
const firstTop = Math.min(...sources.map((source) => source.y));
|
|
431
|
+
if (firstTop - bottom < BUS_MIN_GAP) continue;
|
|
432
|
+
const busY = Math.round(bottom + (firstTop - bottom) / 2);
|
|
433
|
+
const paths = members.map((relationship, index) => {
|
|
434
|
+
const source = sources[index];
|
|
435
|
+
return Math.abs(source.cx - target.cx) < 0.5
|
|
436
|
+
? [[source.cx, source.y], [target.cx, bottom]]
|
|
437
|
+
: [[source.cx, source.y], [source.cx, busY], [target.cx, busY], [target.cx, bottom]];
|
|
438
|
+
});
|
|
439
|
+
// Each drop may touch only its own subtype and the supertype; a deeper
|
|
440
|
+
// subtype's drop must not run through a sibling placed above it.
|
|
441
|
+
const clear = paths.every((points, member) => {
|
|
442
|
+
const others = [...types.values()].filter((type) => type !== target && type !== sources[member]);
|
|
443
|
+
return points.slice(1).every((point, index) => others
|
|
444
|
+
.every((type) => !segmentIntersectsRect({ start: points[index], end: point }, type)));
|
|
445
|
+
});
|
|
446
|
+
if (!clear) continue;
|
|
447
|
+
members.forEach((relationship, index) => {
|
|
448
|
+
busPaths.set(relationship, paths[index]);
|
|
449
|
+
busGroups.set(relationship, members);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let separateFallbackRoutes = false;
|
|
455
|
+
function ordinaryRouter() {
|
|
456
|
+
return createRouter(types, routable.filter((relationship) => !busPaths.has(relationship)), {
|
|
457
|
+
sideFor,
|
|
458
|
+
maxPortSpacing: PORT_SPACING,
|
|
459
|
+
distinctAutomaticPorts: separateFallbackRoutes,
|
|
460
|
+
// As for the ERD: re-plan a route against the complete scene when it crosses
|
|
461
|
+
// or bends needlessly, and look for a detour that crosses nothing before the
|
|
462
|
+
// obstacle search that may cross earlier routes. The showcase gate rejects
|
|
463
|
+
// any proper crossing, so the shorter crossing route only buys a repair.
|
|
464
|
+
preferReadableRoutes: true,
|
|
465
|
+
crossingFreeGridFirst: true,
|
|
466
|
+
// A detour keeps a readable gap from every type it only passes instead of
|
|
467
|
+
// running along its border at the grid's 2-unit clearance.
|
|
468
|
+
componentGapPx: 10,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function busRouteConflict(left, right) {
|
|
473
|
+
for (const [leftField, leftPoint] of [['from', left.points[0]], ['to', left.points.at(-1)]]) {
|
|
474
|
+
for (const [rightField, rightPoint] of [['from', right.points[0]], ['to', right.points.at(-1)]]) {
|
|
475
|
+
if (left.relation[leftField] === right.relation[rightField]
|
|
476
|
+
&& Math.hypot(leftPoint[0] - rightPoint[0], leftPoint[1] - rightPoint[1]) < MARKER_HEIGHT - 0.0001) return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (collectAmbiguousCorridors({
|
|
480
|
+
routedRelations: [left, right],
|
|
481
|
+
includeSharedEndpoints: () => true,
|
|
482
|
+
}).length) return true;
|
|
483
|
+
return left.points.slice(1).some((end, index) => right.points.slice(1)
|
|
484
|
+
.some((otherEnd, otherIndex) => properSegmentIntersection(
|
|
485
|
+
left.points[index], end, right.points[otherIndex], otherEnd,
|
|
486
|
+
)));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let router;
|
|
490
|
+
for (;;) {
|
|
491
|
+
router = ordinaryRouter();
|
|
492
|
+
if (!busPaths.size) break;
|
|
493
|
+
const routes = routable.map((relation) => ({
|
|
494
|
+
relation, points: busPaths.get(relation) || router.pathFor(relation).points,
|
|
495
|
+
}));
|
|
496
|
+
const rejected = new Set();
|
|
497
|
+
for (let leftIndex = 0; leftIndex < routes.length; leftIndex += 1) {
|
|
498
|
+
const left = routes[leftIndex];
|
|
499
|
+
const leftGroup = busGroups.get(left.relation);
|
|
500
|
+
for (let rightIndex = leftIndex + 1; rightIndex < routes.length; rightIndex += 1) {
|
|
501
|
+
const right = routes[rightIndex];
|
|
502
|
+
const rightGroup = busGroups.get(right.relation);
|
|
503
|
+
// 只有同一主干组可以共享端口和线段;其他路线仍保留各自的关系符号。
|
|
504
|
+
if ((!leftGroup && !rightGroup) || leftGroup === rightGroup) continue;
|
|
505
|
+
if (!busRouteConflict(left, right)) continue;
|
|
506
|
+
if (leftGroup) rejected.add(leftGroup);
|
|
507
|
+
if (rightGroup) rejected.add(rightGroup);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (!rejected.size) break;
|
|
511
|
+
// 回退后的独立关系即使共享目标,也不能重新合并成没有语义归属的走廊。
|
|
512
|
+
separateFallbackRoutes = true;
|
|
513
|
+
// 整组回退后重新规划,避免新路线撞到剩余主干;每轮只删除组,次数有界。
|
|
514
|
+
for (const group of rejected) {
|
|
515
|
+
for (const relationship of group) {
|
|
516
|
+
busPaths.delete(relationship);
|
|
517
|
+
busGroups.delete(relationship);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function pathFor(relationship) {
|
|
522
|
+
const points = busPaths.get(relationship);
|
|
523
|
+
return points ? { points, d: roundedPath(points, 8) } : router.pathFor(relationship);
|
|
524
|
+
}
|
|
525
|
+
// A bus drop leaves the subtype's top and the trunk enters the supertype's
|
|
526
|
+
// bottom; every other relationship keeps the router's resolved sides.
|
|
527
|
+
function connectionEndpointSide(relationship, endpoint) {
|
|
528
|
+
if (busPaths.has(relationship)) return endpoint === 'source' ? 'top' : 'bottom';
|
|
529
|
+
return router.connectionEndpointSide(relationship, endpoint);
|
|
530
|
+
}
|
|
531
|
+
function inferredSides(relationship) {
|
|
532
|
+
if (busPaths.has(relationship)) return { fromSide: 'top', toSide: 'bottom' };
|
|
533
|
+
return router.inferredSides(relationship);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const RELATIONSHIP_LABEL_FONT = 9.5;
|
|
537
|
+
const RELATIONSHIP_LABEL_ADVANCE = 0.62;
|
|
538
|
+
function relationshipLabelWidth(relationship) {
|
|
539
|
+
return Math.max(30, textUnits(relationship.label) * RELATIONSHIP_LABEL_FONT * RELATIONSHIP_LABEL_ADVANCE + 10);
|
|
540
|
+
}
|
|
541
|
+
// A label sits on the segment with the most room unless the author picks one:
|
|
542
|
+
// above a horizontal run, beside a vertical one, so it never covers its own
|
|
543
|
+
// line or lands on the box the relationship leaves.
|
|
544
|
+
const resolvedLabelPoints = new Map();
|
|
545
|
+
function relationshipLabelPoint(relationship) {
|
|
546
|
+
return resolvedLabelPoints.get(relationship) ?? defaultRelationshipLabelPoint(relationship);
|
|
547
|
+
}
|
|
548
|
+
function defaultRelationshipLabelPoint(relationship) {
|
|
549
|
+
if (relationship.labelAt) return relationship.labelAt;
|
|
550
|
+
const points = pathFor(relationship).points;
|
|
551
|
+
const width = relationshipLabelWidth(relationship);
|
|
552
|
+
const vertical = (index) => Math.abs(points[index][0] - points[index + 1][0]) < Math.abs(points[index][1] - points[index + 1][1]);
|
|
553
|
+
const room = (index) => {
|
|
554
|
+
const length = Math.hypot(points[index + 1][0] - points[index][0], points[index + 1][1] - points[index][1]);
|
|
555
|
+
return length - (vertical(index) ? 22 : width + 8);
|
|
556
|
+
};
|
|
557
|
+
const index = Number.isInteger(relationship.labelSegment)
|
|
558
|
+
? Math.min(points.length - 2, relationship.labelSegment)
|
|
559
|
+
: points.slice(1).reduce((best, _, candidate) => (room(candidate) > room(best) ? candidate : best), 0);
|
|
560
|
+
const [a, b] = [points[index], points[index + 1]];
|
|
561
|
+
const dx = relationship.labelDx || 0;
|
|
562
|
+
const dy = relationship.labelDy || 0;
|
|
563
|
+
if (vertical(index)) return [a[0] + width / 2 + 6 + dx, (a[1] + b[1]) / 2 + 4 + dy];
|
|
564
|
+
return [(a[0] + b[0]) / 2 + dx, a[1] - 6 + dy];
|
|
565
|
+
}
|
|
566
|
+
function relationshipLabelBox(relationship) {
|
|
567
|
+
const [lx, ly] = relationshipLabelPoint(relationship);
|
|
568
|
+
const width = relationshipLabelWidth(relationship);
|
|
569
|
+
return { x: lx - width / 2, y: ly - 10, width, height: 14 };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// The default point picks the roomiest segment but knows nothing about other
|
|
573
|
+
// routes, markers, or types near it. A colliding unpinned label goes through the
|
|
574
|
+
// shared bounded placer (architecture, dataflow, and erd use it too), with
|
|
575
|
+
// every end marker reserved, so it moves beside its own route instead of
|
|
576
|
+
// failing the gate; a clear default is kept as it is.
|
|
577
|
+
const MARKER_REACH = 20;
|
|
578
|
+
// Half the tallest marker plus the 2-unit overlap the placer tolerates.
|
|
579
|
+
const MARKER_HALF = 10;
|
|
580
|
+
function markerBox([x, y], side) {
|
|
581
|
+
if (side === 'left') return { x: x - MARKER_REACH, y: y - MARKER_HALF, width: MARKER_REACH, height: MARKER_HALF * 2 };
|
|
582
|
+
if (side === 'right') return { x, y: y - MARKER_HALF, width: MARKER_REACH, height: MARKER_HALF * 2 };
|
|
583
|
+
if (side === 'top') return { x: x - MARKER_HALF, y: y - MARKER_REACH, width: MARKER_HALF * 2, height: MARKER_REACH };
|
|
584
|
+
return { x: x - MARKER_HALF, y, width: MARKER_HALF * 2, height: MARKER_REACH };
|
|
585
|
+
}
|
|
586
|
+
{
|
|
587
|
+
const labels = routable.filter((relationship) => relationship.label).map((relationship) => {
|
|
588
|
+
const [lx, ly] = defaultRelationshipLabelPoint(relationship);
|
|
589
|
+
return { relation: relationship, relationIndex: relationships.indexOf(relationship), label: relationship.label,
|
|
590
|
+
...relationshipLabelBox(relationship), lx, ly };
|
|
591
|
+
});
|
|
592
|
+
if (labels.length) {
|
|
593
|
+
const markers = routable.flatMap((relationship) => {
|
|
594
|
+
const { points } = pathFor(relationship);
|
|
595
|
+
return [
|
|
596
|
+
markerBox(points[0], connectionEndpointSide(relationship, 'source')),
|
|
597
|
+
markerBox(points.at(-1), connectionEndpointSide(relationship, 'target')),
|
|
598
|
+
];
|
|
599
|
+
});
|
|
600
|
+
const footprint = legendEntries.length
|
|
601
|
+
? layout.legendH + legendFootprint(legendEntries, { width: Math.max(1, viewBox[0] - layout.margin * 2) }).extraHeight
|
|
602
|
+
: 0;
|
|
603
|
+
const placed = placeAutomaticLabels({
|
|
604
|
+
labels,
|
|
605
|
+
routes: routable.map((relationship) => ({ relationIndex: relationships.indexOf(relationship), points: pathFor(relationship).points })),
|
|
606
|
+
components: [...types.values(), ...markers],
|
|
607
|
+
titles: [],
|
|
608
|
+
viewBox,
|
|
609
|
+
placementBottom: viewBox[1] - footprint,
|
|
610
|
+
keepFallbackNearRoute: true,
|
|
611
|
+
});
|
|
612
|
+
placed.forEach((rect, index) => {
|
|
613
|
+
if (rect !== labels[index]) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- Validation ----------------------------------------------------------------
|
|
619
|
+
function detail(code, message, subject, evidence, supportedFixes) {
|
|
620
|
+
return { code, severity: 'error', message, subject: { diagramType: 'class', ...subject }, evidence, supportedFixes };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function generalizationCycle() {
|
|
624
|
+
const parents = new Map();
|
|
625
|
+
for (const relationship of relationships) {
|
|
626
|
+
if (relationship.kind !== 'inheritance' || !renderableRelationship(relationship)) continue;
|
|
627
|
+
if (!parents.has(relationship.from)) parents.set(relationship.from, []);
|
|
628
|
+
parents.get(relationship.from).push(relationship.to);
|
|
629
|
+
}
|
|
630
|
+
const state = new Map();
|
|
631
|
+
const stack = [];
|
|
632
|
+
const visit = (id) => {
|
|
633
|
+
if (state.get(id) === 'done') return null;
|
|
634
|
+
if (state.get(id) === 'open') return [...stack.slice(stack.indexOf(id)), id];
|
|
635
|
+
state.set(id, 'open');
|
|
636
|
+
stack.push(id);
|
|
637
|
+
for (const parent of parents.get(id) || []) {
|
|
638
|
+
const cycle = visit(parent);
|
|
639
|
+
if (cycle) return cycle;
|
|
640
|
+
}
|
|
641
|
+
stack.pop();
|
|
642
|
+
state.set(id, 'done');
|
|
643
|
+
return null;
|
|
644
|
+
};
|
|
645
|
+
for (const id of parents.keys()) {
|
|
646
|
+
const cycle = visit(id);
|
|
647
|
+
if (cycle) return cycle;
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function validateClassDiagram() {
|
|
653
|
+
const problems = [];
|
|
654
|
+
const details = [];
|
|
655
|
+
const fail = (entry) => {
|
|
656
|
+
problems.push(entry.message);
|
|
657
|
+
details.push(entry);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const seen = new Set();
|
|
661
|
+
const cells = new Map();
|
|
662
|
+
for (const [index, type] of asArray(cd.types).entries()) {
|
|
663
|
+
if (seen.has(type.id)) problems.push(`Type ids must be unique; "${type.id}" is declared twice.`);
|
|
664
|
+
seen.add(type.id);
|
|
665
|
+
if (placedByGrid(type)) {
|
|
666
|
+
if (!Number.isInteger(type.row) || !Number.isInteger(type.col * 2)) {
|
|
667
|
+
problems.push(`Type "${type.id}" needs grid row/col (col may be a half step, e.g. 1.5) or an absolute pos [x,y].`);
|
|
668
|
+
} else {
|
|
669
|
+
// A half column straddles the two columns beside it.
|
|
670
|
+
for (const col of new Set([Math.floor(type.col), Math.ceil(type.col)])) {
|
|
671
|
+
const key = `${type.row},${col}`;
|
|
672
|
+
if (cells.has(key)) problems.push(`Types "${cells.get(key)}" and "${type.id}" share grid cell row ${type.row} col ${col}.`);
|
|
673
|
+
else cells.set(key, type.id);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const attributeNames = new Set();
|
|
678
|
+
for (const attribute of asArray(type.attributes)) {
|
|
679
|
+
if (attributeNames.has(attribute.name)) problems.push(`Type "${type.id}" declares attribute "${attribute.name}" twice.`);
|
|
680
|
+
attributeNames.add(attribute.name);
|
|
681
|
+
}
|
|
682
|
+
// Members wrap, but the title is one line: a box must be wide enough for it.
|
|
683
|
+
const placed = types.get(type.id);
|
|
684
|
+
const needed = headerWidth(type);
|
|
685
|
+
if (placed && needed > placed.width) {
|
|
686
|
+
fail(detail('class/title-text-capacity',
|
|
687
|
+
`Type "${type.id}" title "${type.label}" needs ${needed}px but the type is ${placed.width}px wide — remove or raise the authored width, or shorten the title.`,
|
|
688
|
+
{ path: `/types/${index}/width`, typeId: type.id },
|
|
689
|
+
{ label: type.label, width: placed.width, requiredWidth: needed },
|
|
690
|
+
['Remove the authored width so the type sizes itself.', `Set width to at least ${needed}.`, 'Shorten the type title.']));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (const type of types.values()) {
|
|
695
|
+
for (const other of types.values()) {
|
|
696
|
+
if (type.id >= other.id || !rectsOverlap(type, other, 0)) continue;
|
|
697
|
+
problems.push(`Type "${type.id}" overlaps type "${other.id}".\n${suggestComponentSeparation(type, other)}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
702
|
+
const id = relationship.id ? ` id "${relationship.id}"` : '';
|
|
703
|
+
const from = types.get(relationship.from);
|
|
704
|
+
const to = types.get(relationship.to);
|
|
705
|
+
if (!from || !to) {
|
|
706
|
+
problems.push(`Relationship ${index}${id} references unknown type "${from ? relationship.to : relationship.from}".`);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (relationship.from === relationship.to) {
|
|
710
|
+
problems.push(`Relationship ${index}${id} connects type "${relationship.from}" to itself — describe the self-reference as a member type or in a card.`);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
// The hollow triangle claims a supertype; the claim has to agree with the
|
|
714
|
+
// kinds, or the diagram states an impossible hierarchy.
|
|
715
|
+
if (relationship.kind === 'realization' && (to.kind !== 'interface' || from.kind === 'interface')) {
|
|
716
|
+
fail(detail('class/realization-target',
|
|
717
|
+
`Relationship ${index}${id} "${relationship.from}" realizes "${relationship.to}", but realization runs from a non-interface type to an interface (here ${from.kind} -> ${to.kind}). Use inheritance for class-to-class or interface-to-interface extension.`,
|
|
718
|
+
{ path: `/relationships/${index}`, relationshipIndex: index },
|
|
719
|
+
{ from: relationship.from, fromKind: from.kind, to: relationship.to, toKind: to.kind },
|
|
720
|
+
['Use kind "inheritance" for extension between two classes or two interfaces.', 'Point the realization at the interface it implements.']));
|
|
721
|
+
}
|
|
722
|
+
if (relationship.kind === 'inheritance' && (from.kind === 'interface') !== (to.kind === 'interface')) {
|
|
723
|
+
fail(detail('class/inheritance-kind',
|
|
724
|
+
`Relationship ${index}${id} "${relationship.from}" inherits from "${relationship.to}" across an interface boundary (${from.kind} -> ${to.kind}). A class implementing an interface is a realization.`,
|
|
725
|
+
{ path: `/relationships/${index}`, relationshipIndex: index },
|
|
726
|
+
{ from: relationship.from, fromKind: from.kind, to: relationship.to, toKind: to.kind },
|
|
727
|
+
['Use kind "realization" for a class implementing an interface.']));
|
|
728
|
+
}
|
|
729
|
+
const routed = pathFor(relationship);
|
|
730
|
+
const [start, end] = [routed.points[0], routed.points.at(-1)];
|
|
731
|
+
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
732
|
+
if (distance < layout.minRelationshipLength) {
|
|
733
|
+
problems.push(`Relationship ${index}${id} "${relationship.from}" -> "${relationship.to}" is too short (${Math.round(distance)}px; minimum ${layout.minRelationshipLength}px) — place the types farther apart.`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const cycle = generalizationCycle();
|
|
738
|
+
if (cycle) {
|
|
739
|
+
fail(detail('class/inheritance-cycle',
|
|
740
|
+
`Inheritance forms a cycle: ${cycle.map((id) => `"${id}"`).join(' -> ')}. A type cannot be its own supertype.`,
|
|
741
|
+
{ path: '/relationships', typeId: cycle[0] },
|
|
742
|
+
{ cycle },
|
|
743
|
+
['Remove or reverse one inheritance relationship in the cycle.']));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Busy sides can leave less room than a marker is tall: the router spreads
|
|
747
|
+
// ports by min(PORT_SPACING, available / (n - 1)).
|
|
748
|
+
for (const [typeId, type] of types) {
|
|
749
|
+
const sides = new Map();
|
|
750
|
+
// A bus enters its supertype through one shared trunk, which is one port.
|
|
751
|
+
for (const relationship of routable.filter((candidate) => !busPaths.has(candidate))) {
|
|
752
|
+
const { fromSide, toSide } = inferredSides(relationship);
|
|
753
|
+
if (relationship.from === typeId) sides.set(fromSide, (sides.get(fromSide) || 0) + 1);
|
|
754
|
+
if (relationship.to === typeId) sides.set(toSide, (sides.get(toSide) || 0) + 1);
|
|
755
|
+
}
|
|
756
|
+
for (const [side, count] of sides) {
|
|
757
|
+
if (count < 2) continue;
|
|
758
|
+
const extent = side === 'left' || side === 'right' ? type.height : type.width;
|
|
759
|
+
const spacing = Math.min(PORT_SPACING, Math.max(0, extent - 32) / (count - 1));
|
|
760
|
+
if (spacing >= MARKER_HEIGHT) continue;
|
|
761
|
+
fail({
|
|
762
|
+
...detail('layout/marker-capacity',
|
|
763
|
+
`Type "${typeId}" shares its ${side} side with ${count} relationship ends ${Math.round(spacing)}px apart, under the ${MARKER_HEIGHT}px a relationship marker needs, so the markers overlap. Spread the relationships across sides or place the related types so they arrive from different directions.`,
|
|
764
|
+
{ path: `/types/${typeId}` },
|
|
765
|
+
{ type: typeId, side, relationshipEnds: count, spacingPx: Math.round(spacing), markerHeightPx: MARKER_HEIGHT, sideExtentPx: extent },
|
|
766
|
+
['Spread the relationships across sides with room.', 'Move related types so they approach from different sides.']),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const shared = {
|
|
772
|
+
relations: routable,
|
|
773
|
+
endpointIds: new Set(types.keys()),
|
|
774
|
+
pathFor,
|
|
775
|
+
diagramType: 'class',
|
|
776
|
+
relationCollection: 'relationships',
|
|
777
|
+
profile: cd.meta?.quality_profile,
|
|
778
|
+
};
|
|
779
|
+
problems.push(...cleanEndpointSideProblems({
|
|
780
|
+
...shared,
|
|
781
|
+
fromSideFor: (relationship) => connectionEndpointSide(relationship, 'source'),
|
|
782
|
+
toSideFor: (relationship) => connectionEndpointSide(relationship, 'target'),
|
|
783
|
+
routeHint: 'keep automatic routing, or set truthful fromSide/toSide',
|
|
784
|
+
}));
|
|
785
|
+
problems.push(...cleanFlowProblems({
|
|
786
|
+
relations: routable,
|
|
787
|
+
obstacles: types.values(),
|
|
788
|
+
pathFor,
|
|
789
|
+
diagramType: 'class',
|
|
790
|
+
relationCollection: 'relationships',
|
|
791
|
+
obstacleKind: 'type',
|
|
792
|
+
routeHint: 'adjust fromSide/toSide, set route/via, or move the type',
|
|
793
|
+
}));
|
|
794
|
+
problems.push(...cleanCrossingProblems({ ...shared, routeHint: 'move the types so unrelated relationships use separate corridors' }));
|
|
795
|
+
problems.push(...cleanAmbiguousCorridorProblems({ ...shared, routeHint: 'give the relationships different sides, or move a type so their corridors differ' }));
|
|
796
|
+
problems.push(...cleanRouteRhythmProblems({ ...shared, routeHint: 'widen layout.gapX/gapY so every turn has room to read' }));
|
|
797
|
+
|
|
798
|
+
const labels = [];
|
|
799
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
800
|
+
if (!relationship.label || !renderableRelationship(relationship)) continue;
|
|
801
|
+
const [lx, ly] = relationshipLabelPoint(relationship);
|
|
802
|
+
labels.push({ relation: relationship, relationIndex: index, label: relationship.label, ...relationshipLabelBox(relationship), lx, ly });
|
|
803
|
+
}
|
|
804
|
+
for (const rect of labels) {
|
|
805
|
+
for (const type of types.values()) {
|
|
806
|
+
if (!rectsOverlap(rect, type, -2)) continue;
|
|
807
|
+
problems.push(`Label "${rect.label}" overlaps type "${type.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, type, 'type')}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
problems.push(...cleanLabelRouteClearanceProblems({ ...shared, labels }));
|
|
811
|
+
|
|
812
|
+
if (problems.length) {
|
|
813
|
+
throwDiagnosticProblems('Class diagram validation failed', problems, {
|
|
814
|
+
code: 'layout/constraint',
|
|
815
|
+
subject: { diagramType: 'class' },
|
|
816
|
+
diagnostics: details,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function buildLayoutReport() {
|
|
822
|
+
return {
|
|
823
|
+
diagram_type: 'class',
|
|
824
|
+
viewBox,
|
|
825
|
+
types: [...types.values()].map((type) => ({
|
|
826
|
+
id: type.id,
|
|
827
|
+
label: type.label,
|
|
828
|
+
kind: type.kind,
|
|
829
|
+
x: Math.round(type.x),
|
|
830
|
+
y: Math.round(type.y),
|
|
831
|
+
width: type.width,
|
|
832
|
+
height: type.height,
|
|
833
|
+
...(Number.isInteger(type.row) ? { row: type.row } : {}),
|
|
834
|
+
...(Number.isInteger(type.col) ? { col: type.col } : {}),
|
|
835
|
+
})),
|
|
836
|
+
relationships: routable.map((relationship) => ({
|
|
837
|
+
...relationshipPath(relationship, pathFor(relationship), relationship.labelAt),
|
|
838
|
+
kind: relationship.kind,
|
|
839
|
+
})),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ---- Rendering -----------------------------------------------------------------
|
|
844
|
+
// A member reads like a signature in an editor: the visibility glyph and every
|
|
845
|
+
// type annotation are quiet, so the names carry the row. Wrapping never splits
|
|
846
|
+
// an annotation, so each line is coloured on its own.
|
|
847
|
+
const VISIBILITY_CLASS = { '+': 'public', '#': 'protected', '~': 'package', '-': 'private' };
|
|
848
|
+
|
|
849
|
+
function punctuated(text) {
|
|
850
|
+
return esc(text).replace(/[(),]/g, (mark) => `<tspan class="t-muted cl-punct">${mark}</tspan>`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function memberSpans(text, first) {
|
|
854
|
+
let out = '';
|
|
855
|
+
let rest = text;
|
|
856
|
+
// A continuation line with no annotation of its own is the rest of a type
|
|
857
|
+
// (`PaymentProcessor>`), so it is set like the start of that type.
|
|
858
|
+
if (!first && !rest.includes(':') && !rest.startsWith(')')) return `<tspan class="t-muted cl-type-ref">${esc(rest)}</tspan>`;
|
|
859
|
+
const glyph = first ? rest.match(/^([+#~-]) /) : null;
|
|
860
|
+
if (glyph) {
|
|
861
|
+
out += `<tspan class="t-muted cl-vis cl-vis-${VISIBILITY_CLASS[glyph[1]]}">${esc(glyph[1])}</tspan> `;
|
|
862
|
+
rest = rest.slice(2);
|
|
863
|
+
}
|
|
864
|
+
const annotation = /:(\s*)([^,()]+)/g;
|
|
865
|
+
let last = 0;
|
|
866
|
+
for (const match of rest.matchAll(annotation)) {
|
|
867
|
+
out += `${punctuated(rest.slice(last, match.index))}<tspan class="t-muted cl-punct">:</tspan>${esc(match[1])}<tspan class="t-muted cl-type-ref">${esc(match[2])}</tspan>`;
|
|
868
|
+
last = match.index + match[0].length;
|
|
869
|
+
}
|
|
870
|
+
return out + punctuated(rest.slice(last));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// The visibility glyph is set as a chip in its own column, like a key badge in
|
|
874
|
+
// an ERD; a one-line member puts its type in a right-aligned column so the
|
|
875
|
+
// names and the types each read down one edge.
|
|
876
|
+
function visibilityChip(glyph, x, baseline) {
|
|
877
|
+
const kind = VISIBILITY_CLASS[glyph];
|
|
878
|
+
return `<rect x="${x}" y="${baseline - 10.5}" width="${VIS_CHIP}" height="${VIS_CHIP}" rx="4" class="cl-vis-chip cl-vis-${kind}"/><text x="${x + VIS_CHIP / 2}" y="${baseline}" class="t-muted cl-vis cl-vis-${kind}" font-size="${layout.memberFont}" text-anchor="middle">${esc(glyph)}</text>`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function memberColumns(member, kind) {
|
|
882
|
+
if (kind === 'attributes') return { left: punctuated(member.name), right: member.type };
|
|
883
|
+
const parameters = member.parameters
|
|
884
|
+
? String(member.parameters).split(/(,\s*)/).map((part) => {
|
|
885
|
+
if (/^,\s*$/.test(part)) return `<tspan class="t-muted cl-punct">,</tspan> `;
|
|
886
|
+
const [name, ...annotation] = part.split(':');
|
|
887
|
+
return annotation.length
|
|
888
|
+
? `<tspan class="cl-param">${esc(name)}</tspan><tspan class="t-muted cl-punct">:</tspan><tspan class="t-muted cl-type-ref">${esc(annotation.join(':'))}</tspan>`
|
|
889
|
+
: `<tspan class="cl-param">${esc(part)}</tspan>`;
|
|
890
|
+
}).join('')
|
|
891
|
+
: '';
|
|
892
|
+
return {
|
|
893
|
+
left: `${esc(member.name)}<tspan class="t-muted cl-punct">(</tspan>${parameters}<tspan class="t-muted cl-punct">)</tspan>`,
|
|
894
|
+
right: member.returns,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function renderMembers(type) {
|
|
899
|
+
let top = type.y + headerHeight(type);
|
|
900
|
+
let stripe = 0;
|
|
901
|
+
return compartments(type).map((compartment) => {
|
|
902
|
+
const rule = `<line x1="${type.x}" y1="${top}" x2="${type.x + type.width}" y2="${top}" class="cl-rule" stroke-width="1"/>`;
|
|
903
|
+
let line = 0;
|
|
904
|
+
const rows = compartment.rows.flatMap(({ member, lines }) => {
|
|
905
|
+
const style = [
|
|
906
|
+
member.static ? 'text-decoration: underline' : '',
|
|
907
|
+
member.abstract ? 'font-style: italic' : '',
|
|
908
|
+
].filter(Boolean).join('; ');
|
|
909
|
+
const styleAttr = style ? ` style="${style}"` : '';
|
|
910
|
+
const rowTop = top + layout.compartmentPad + line * layout.rowH;
|
|
911
|
+
const zebra = stripe++ % 2
|
|
912
|
+
? `<rect x="${type.x + 4}" y="${rowTop + 1}" width="${type.width - 8}" height="${lines.length * layout.rowH - 2}" rx="4" class="cl-zebra"/>`
|
|
913
|
+
: '';
|
|
914
|
+
const glyph = member.visibility ? VISIBILITY_GLYPH[member.visibility] : '';
|
|
915
|
+
const x0 = type.x + layout.padX;
|
|
916
|
+
const chip = glyph ? visibilityChip(glyph, x0, rowTop + layout.rowH - 5) : '';
|
|
917
|
+
const textX = glyph ? x0 + VIS_CHIP + VIS_GAP : x0;
|
|
918
|
+
if (lines.length === 1) {
|
|
919
|
+
const baseline = top + layout.compartmentPad + (line += 1) * layout.rowH - 5;
|
|
920
|
+
const { left, right } = memberColumns(member, compartment.kind);
|
|
921
|
+
const typeText = right
|
|
922
|
+
? `<text data-detail="context" x="${type.x + type.width - layout.padX}" y="${baseline}" class="t-muted cl-member cl-type-ref" font-size="${layout.memberFont}" text-anchor="end"${styleAttr}>${esc(right)}</text>`
|
|
923
|
+
: '';
|
|
924
|
+
return [`${zebra}${chip}<text data-detail="context" data-class-member="${compartment.kind}" x="${textX}" y="${baseline}" class="t-primary cl-member" font-size="${layout.memberFont}"${styleAttr}>${left}</text>${typeText}`];
|
|
925
|
+
}
|
|
926
|
+
return lines.map((entry, index) => {
|
|
927
|
+
const baseline = top + layout.compartmentPad + (line += 1) * layout.rowH - 5;
|
|
928
|
+
const text = index || !glyph ? entry.text : entry.text.slice(2);
|
|
929
|
+
const x = index || !glyph ? x0 + (glyph ? VIS_CHIP + VIS_GAP - 2 * MEMBER_ADVANCE : 0) + entry.indent * MEMBER_ADVANCE : textX;
|
|
930
|
+
const continued = index ? ' data-class-continuation=""' : '';
|
|
931
|
+
return `${index ? '' : `${zebra}${chip}`}<text data-detail="context" data-class-member="${compartment.kind}"${continued} x="${x}" y="${baseline}" class="t-primary cl-member" font-size="${layout.memberFont}"${styleAttr}>${memberSpans(text, !index)}</text>`;
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
top += line * layout.rowH + layout.compartmentPad * 2;
|
|
935
|
+
return ` <g data-detail="context" data-class-compartment="${compartment.kind}">
|
|
936
|
+
${rule}
|
|
937
|
+
${rows.join('\n ')}
|
|
938
|
+
</g>`;
|
|
939
|
+
}).join('\n');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function renderType(type) {
|
|
943
|
+
const tone = KIND_TONE[type.kind] || 'backend';
|
|
944
|
+
const header = headerHeight(type);
|
|
945
|
+
const stereotype = STEREOTYPE[type.kind];
|
|
946
|
+
const kindLabel = i18nText(locale, `node.context.class.${type.kind}`);
|
|
947
|
+
const passport = { kind: type.kind, sublabel: type.sublabel, context: kindLabel };
|
|
948
|
+
const cx = type.x + type.width / 2;
|
|
949
|
+
const nameY = stereotype ? type.y + 34 : type.y + 22;
|
|
950
|
+
const badgeX = type.x + layout.padX;
|
|
951
|
+
const badgeY = type.y + (stereotype ? 14 : 9);
|
|
952
|
+
const italic = type.kind === 'abstract' ? ' font-style="italic"' : '';
|
|
953
|
+
const r = 8;
|
|
954
|
+
return ` <g ${focusNodeAttrs(type.id, type.label, passport, locale)} data-class-kind="${esc(type.kind)}" class="cl-hue-${tone}">
|
|
955
|
+
${focusNodeTitle(type.label, passport)}
|
|
956
|
+
<rect x="${type.x}" y="${type.y}" width="${type.width}" height="${type.height}" rx="${r}" class="c-mask"/>
|
|
957
|
+
<rect x="${type.x}" y="${type.y}" width="${type.width}" height="${type.height}" rx="${r}" class="c-${tone} cl-type"${animateAttr(cd.meta, 'node', typeSteps.get(type.id))} stroke-width="1.2"/>
|
|
958
|
+
<path class="cl-header c-${tone}" stroke="none" d="M ${type.x + r} ${type.y} H ${type.x + type.width - r} A ${r} ${r} 0 0 1 ${type.x + type.width} ${type.y + r} V ${type.y + header} H ${type.x} V ${type.y + r} A ${r} ${r} 0 0 1 ${type.x + r} ${type.y} Z"/>
|
|
959
|
+
<path class="cl-cap" d="M ${type.x + 12} ${type.y + 1.4} H ${type.x + type.width - 12}" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
|
960
|
+
<g aria-hidden="true" class="cl-badge">
|
|
961
|
+
<rect x="${badgeX}" y="${badgeY}" width="${BADGE_SIZE}" height="${BADGE_SIZE}" rx="${BADGE_SIZE * 0.3}" class="cl-badge-chip"/>
|
|
962
|
+
<text x="${badgeX + BADGE_SIZE / 2}" y="${badgeY + BADGE_SIZE / 2 + 3.5}" class="t-${tone} cl-badge-letter" font-size="10" font-weight="800" text-anchor="middle">${esc(type.kind[0].toUpperCase())}</text>
|
|
963
|
+
</g>
|
|
964
|
+
${stereotype ? `<text x="${cx}" y="${type.y + 16}" class="t-muted cl-stereotype" font-size="${layout.stereotypeFont}" text-anchor="middle">${esc(stereotype)}</text>` : ''}
|
|
965
|
+
<text data-node-label="" x="${cx}" y="${nameY}" class="t-primary" font-size="${layout.nameFont}" font-weight="700" text-anchor="middle"${italic}>${esc(type.label)}</text>
|
|
966
|
+
${renderMembers(type)}
|
|
967
|
+
</g>`;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function markerAttrs(kind, { reversed = false } = {}) {
|
|
971
|
+
const notation = NOTATION[kind];
|
|
972
|
+
if (reversed) return ` marker-start="url(#cl-${notation.end}-start)"`;
|
|
973
|
+
return `${notation.start ? ` marker-start="url(#cl-${notation.start})"` : ''}${notation.end ? ` marker-end="url(#cl-${notation.end})"` : ''}`;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function relationshipClass(kind) {
|
|
977
|
+
return `a-default cl-relationship${NOTATION[kind].dashed ? ' cl-dashed' : ''}`;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Bus members share their trunk and bus, so each is drawn starting at the
|
|
981
|
+
// supertype: the overlapping stretches then begin at one point and their dash
|
|
982
|
+
// patterns coincide instead of beating into a smeared line.
|
|
983
|
+
function renderRelationshipPath(relationship, index) {
|
|
984
|
+
const routed = pathFor(relationship);
|
|
985
|
+
const reversed = busPaths.has(relationship);
|
|
986
|
+
const d = reversed ? roundedPath([...routed.points].reverse(), 8) : routed.d;
|
|
987
|
+
const bus = reversed ? ` data-class-bus="${esc(relationship.to)}" data-motion-path="${esc(routed.d)}"` : '';
|
|
988
|
+
return ` <path ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-class-relationship="${relationship.kind}"${bus} data-composition-points="${routePointsValue(routed.points)}" d="${d}" class="${relationshipClass(relationship.kind)}"${animateAttr(cd.meta, 'edge', index)} stroke-width="1.5"${markerAttrs(relationship.kind, { reversed })}/>`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function renderRelationshipLabel(relationship, index) {
|
|
992
|
+
if (!relationship.label) return '';
|
|
993
|
+
const [lx, ly] = relationshipLabelPoint(relationship);
|
|
994
|
+
const box = relationshipLabelBox(relationship);
|
|
995
|
+
return ` <g data-detail="context" ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-class-relationship="${relationship.kind}">
|
|
996
|
+
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="${box.height / 2}" class="c-mask cl-label-box"/>
|
|
997
|
+
<text x="${lx}" y="${ly}" class="t-muted" font-size="${RELATIONSHIP_LABEL_FONT}" text-anchor="middle">${esc(relationship.label)}</text>
|
|
998
|
+
</g>`;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// A swatch is a short relationship drawn in the same notation: dash, line, and
|
|
1002
|
+
// the marker at the end it belongs to.
|
|
1003
|
+
function legendSwatch(entry) {
|
|
1004
|
+
const notation = NOTATION[entry.kind];
|
|
1005
|
+
const y = entry.baseline - 4;
|
|
1006
|
+
const x0 = entry.x + 2;
|
|
1007
|
+
const x1 = entry.x + LEGEND_SWATCH - 2;
|
|
1008
|
+
const hit = `<rect class="cl-legend-hit" tabindex="0" role="img" aria-label="${esc(entry.label)}" x="${entry.x - 6}" y="${entry.baseline - 15}" width="${entry.width + 12}" height="22" rx="11"/>`;
|
|
1009
|
+
return `${hit}<path d="M ${x0} ${y} L ${x1} ${y}" class="${relationshipClass(entry.kind)}" stroke-width="1.5"${markerAttrs(entry.kind)}/>`;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const LEGEND_HOVER_RULES = RELATIONSHIP_KINDS.map((kind) => ` svg[data-class-ui]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-class-relationship]:not([data-class-relationship="${kind}"]) { opacity: .15; }`).join('\n');
|
|
1013
|
+
|
|
1014
|
+
function renderLegend() {
|
|
1015
|
+
const obstacles = relationshipLegendObstacles(routable, {
|
|
1016
|
+
pointsFor: (relationship) => pathFor(relationship).points,
|
|
1017
|
+
labelRectFor: (relationship) => (relationship.label ? relationshipLabelBox(relationship) : null),
|
|
1018
|
+
});
|
|
1019
|
+
return renderResolvedLegend({
|
|
1020
|
+
entries: legendEntries,
|
|
1021
|
+
locale,
|
|
1022
|
+
layout: legendLayout(viewBox[0], viewBox[1], { obstacles }),
|
|
1023
|
+
renderSwatch: legendSwatch,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function renderSvg() {
|
|
1028
|
+
// Like the ERD, an automatic canvas lets the reader scroll a tall model
|
|
1029
|
+
// instead of shrinking member rows past legibility.
|
|
1030
|
+
const readerFit = cd.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height" data-reader-min-text="7.5"';
|
|
1031
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(cd.meta)} data-class-ui=""${readerFit}>
|
|
1032
|
+
${svgAccessibleText(cd.meta, 'class')}
|
|
1033
|
+
${renderDefinitions(renderMarkerDefs())}
|
|
1034
|
+
<style>
|
|
1035
|
+
svg[data-class-ui] .cl-zebra { fill: color-mix(in srgb, var(--cl-hue) 6%, transparent); }
|
|
1036
|
+
svg[data-class-ui] .cl-param { fill: color-mix(in srgb, var(--text) 72%, var(--text-muted)); }
|
|
1037
|
+
${[...new Set(Object.values(KIND_TONE))].map((tone) => ` svg[data-class-ui] .cl-hue-${tone} { --cl-hue: var(--${tone}-stroke); }`).join('\n')}
|
|
1038
|
+
svg[data-class-ui] .cl-type { fill: var(--mask); stroke: color-mix(in srgb, var(--cl-hue) 46%, var(--lane-stroke)); }
|
|
1039
|
+
svg[data-class-ui] [data-class-kind="abstract"] .cl-type { stroke-dasharray: 5 3; stroke: color-mix(in srgb, var(--cl-hue) 64%, var(--lane-stroke)); }
|
|
1040
|
+
svg[data-class-ui] .cl-header { stroke: none; }
|
|
1041
|
+
svg[data-class-ui] .cl-cap { stroke: var(--cl-hue); pointer-events: none; }
|
|
1042
|
+
svg[data-class-ui] .cl-badge-chip { fill: var(--cl-hue); }
|
|
1043
|
+
svg[data-class-ui] .cl-badge-letter { fill: var(--mask); }
|
|
1044
|
+
svg[data-class-ui] .cl-stereotype { fill: var(--text-muted); letter-spacing: .04em; }
|
|
1045
|
+
svg[data-class-ui] .cl-rule { stroke: color-mix(in srgb, var(--cl-hue) 22%, var(--lane-stroke)); }
|
|
1046
|
+
svg[data-class-ui] .cl-vis { font-weight: 700; }
|
|
1047
|
+
svg[data-class-ui] .cl-vis-chip { fill: color-mix(in srgb, var(--text) 6%, transparent); }
|
|
1048
|
+
svg[data-class-ui] .cl-type-ref { fill: var(--text-muted); }
|
|
1049
|
+
svg[data-class-ui] .cl-punct { fill: var(--text-muted); opacity: .8; }
|
|
1050
|
+
svg[data-class-ui] .cl-label-box { stroke: var(--lane-stroke); stroke-width: .8; }
|
|
1051
|
+
svg[data-class-ui] .cl-member { font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', 'Liberation Mono', 'Noto Sans Mono CJK SC', 'PingFang SC', 'Microsoft YaHei', monospace; }
|
|
1052
|
+
svg[data-class-ui] .cl-dashed { stroke-dasharray: 6 4; }
|
|
1053
|
+
svg[data-class-ui] .cl-marker { stroke: var(--arrow); }
|
|
1054
|
+
svg[data-class-ui] .cl-marker-hollow { fill: var(--mask); }
|
|
1055
|
+
svg[data-class-ui] .cl-marker-filled { fill: var(--arrow); }
|
|
1056
|
+
svg[data-class-ui] .cl-legend-hit { fill: transparent; transition: fill .15s; }
|
|
1057
|
+
svg[data-class-ui] .cl-legend-hit:focus { outline: none; }
|
|
1058
|
+
svg[data-class-ui] .cl-legend-hit:focus-visible { stroke: var(--arrow); stroke-width: 1.5; }
|
|
1059
|
+
svg[data-class-ui] [data-legend-semantic-kind]:is(:hover, :focus-within) .cl-legend-hit { fill: var(--lane-fill); }
|
|
1060
|
+
svg[data-class-ui] [data-class-relationship] { transition: opacity .15s; }
|
|
1061
|
+
${LEGEND_HOVER_RULES}
|
|
1062
|
+
@media (prefers-reduced-motion: reduce) { svg[data-class-ui] * { transition: none !important; } }
|
|
1063
|
+
</style>
|
|
1064
|
+
|
|
1065
|
+
<!-- Background Grid -->
|
|
1066
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
1067
|
+
|
|
1068
|
+
<!-- Relationships (before types for correct z-order) -->
|
|
1069
|
+
${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipPath(relationship, index) : '')).filter(Boolean).join('\n')}
|
|
1070
|
+
|
|
1071
|
+
<!-- Types -->
|
|
1072
|
+
${[...types.values()].map(renderType).join('\n\n')}
|
|
1073
|
+
|
|
1074
|
+
<!-- Relationship labels -->
|
|
1075
|
+
${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipLabel(relationship, index) : '')).filter(Boolean).join('\n')}
|
|
1076
|
+
|
|
1077
|
+
<!-- Legend -->
|
|
1078
|
+
${renderLegend()}
|
|
1079
|
+
</svg>`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
validateClassDiagram();
|
|
1083
|
+
if (layoutJsonMode) {
|
|
1084
|
+
console.log(JSON.stringify(buildLayoutReport(), null, 2));
|
|
1085
|
+
process.exit(0);
|
|
1086
|
+
}
|
|
1087
|
+
writeDiagram({
|
|
1088
|
+
outPath,
|
|
1089
|
+
template,
|
|
1090
|
+
diagramType: 'class',
|
|
1091
|
+
meta: cd.meta,
|
|
1092
|
+
svg: renderSvg(),
|
|
1093
|
+
cards: cd.cards,
|
|
1094
|
+
sourceEvidence,
|
|
1095
|
+
});
|