@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.
Files changed (208) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +2 -2
  3. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  4. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  5. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  8. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  9. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  10. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  11. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  16. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  17. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  21. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  22. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  24. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  27. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  28. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  37. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  38. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  39. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  40. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  42. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  43. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  56. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  57. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  58. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  59. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  69. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  70. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  111. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  120. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  121. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  122. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  124. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  125. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  126. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  127. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  128. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  129. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  130. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  134. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  141. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  147. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  149. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  175. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  186. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  187. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  188. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  189. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  190. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  191. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  192. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  194. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  195. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  196. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  199. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  201. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  205. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  206. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  207. package/ide/vscode/package.json +1 -1
  208. 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
+ });