agentic-mermaid 0.1.1 → 0.2.0

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 (340) hide show
  1. package/AGENT_NATIVE.md +155 -91
  2. package/CHANGELOG.md +163 -4
  3. package/Instructions_for_agents.md +10 -10
  4. package/LICENSES/Apache-2.0.txt +202 -0
  5. package/README.md +31 -38
  6. package/THIRD_PARTY_NOTICES.md +29 -0
  7. package/assets/hero.png +0 -0
  8. package/dist/agent-core.d.ts +463 -0
  9. package/dist/agent-core.js +397 -0
  10. package/dist/agent.d.ts +73 -1868
  11. package/dist/agent.js +336 -37325
  12. package/dist/agentic-mermaid-mcp.js +7 -38399
  13. package/dist/am.js +33626 -40446
  14. package/dist/chunk-2P5FKUQC.js +3485 -0
  15. package/dist/chunk-2S3VSS6I.js +149 -0
  16. package/dist/chunk-2SILJDW2.js +37 -0
  17. package/dist/chunk-BJYMBT4I.js +61963 -0
  18. package/dist/chunk-FDM43XU7.js +615 -0
  19. package/dist/chunk-MYPDVZCO.js +4702 -0
  20. package/dist/index-DWPa2MJs.d.ts +6780 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +310 -33014
  23. package/dist/mcp-cli-5J47WEY3.js +17 -0
  24. package/docs/README.md +39 -31
  25. package/docs/agent-api-cookbook.md +44 -35
  26. package/docs/agent-mutation-policy.md +1 -1
  27. package/docs/agent-workflow-examples.md +2 -2
  28. package/docs/api.md +284 -77
  29. package/docs/ascii.md +26 -16
  30. package/docs/code-mode-impact.md +214 -0
  31. package/docs/comparison.md +23 -11
  32. package/docs/computational-aesthetics-prototype-plan.md +252 -0
  33. package/docs/config.md +26 -10
  34. package/docs/custom-fonts.md +265 -0
  35. package/docs/custom-style-cookbook.md +130 -9
  36. package/docs/diagram-families.md +48 -23
  37. package/docs/features.md +59 -52
  38. package/docs/fork-differences.md +6 -3
  39. package/docs/getting-started.md +23 -16
  40. package/docs/issue-derived-test-cases.md +29 -22
  41. package/docs/mcp-code-mode-rationale.md +13 -6
  42. package/docs/mcp-http-transport.md +20 -13
  43. package/docs/mermaid-layout-complaints.md +66 -52
  44. package/docs/mutation-testing.md +145 -66
  45. package/docs/quality.md +23 -14
  46. package/docs/react.md +2 -2
  47. package/docs/schemas/style-spec.schema.json +1221 -30
  48. package/docs/style-authoring.md +241 -38
  49. package/docs/svg-semantic-contract.md +49 -0
  50. package/docs/testing-strategy.md +166 -58
  51. package/docs/theming.md +19 -32
  52. package/examples/styles/catalog.json +54 -0
  53. package/examples/styles/cloudflare-workers-inspired-prototype.mmd +9 -0
  54. package/examples/styles/cloudflare-workers-inspired-prototype.style.json +110 -0
  55. package/examples/styles/cupertino-prototype.mmd +10 -0
  56. package/examples/styles/cupertino-prototype.style.json +63 -0
  57. package/examples/styles/mid-century-report.style.json +2 -2
  58. package/examples/styles/star-chart-atlas.style.json +2 -1
  59. package/examples/styles/transit-route-map.style.json +2 -2
  60. package/examples/styles/vercel-inspired-prototype.mmd +6 -0
  61. package/examples/styles/vercel-inspired-prototype.style.json +87 -0
  62. package/package.json +85 -45
  63. package/server.json +2 -2
  64. package/skills/agentic-mermaid-diagram-workflow/SKILL.md +17 -36
  65. package/skills/agentic-mermaid-diagram-workflow/references/cli.md +4 -5
  66. package/skills/agentic-mermaid-diagram-workflow/references/code-mode.md +15 -31
  67. package/skills/agentic-mermaid-diagram-workflow/references/sequence.md +10 -14
  68. package/skills/agentic-mermaid-live-editor/SKILL.md +100 -206
  69. package/TODO.md +0 -684
  70. package/dist/index-wjRjbsP-.d.ts +0 -1102
  71. package/docs/assets/improvements/box-start-connector.png +0 -0
  72. package/docs/assets/improvements/comment-dropped-lint.png +0 -0
  73. package/docs/assets/improvements/er-content-loss.png +0 -0
  74. package/docs/assets/improvements/fan-in-trunks.png +0 -0
  75. package/docs/assets/improvements/gantt-family.png +0 -0
  76. package/docs/assets/improvements/gantt-milestone-clamp.png +0 -0
  77. package/docs/assets/improvements/gantt-verify-seam.png +0 -0
  78. package/docs/assets/improvements/subgraph-container-edges.png +0 -0
  79. package/docs/assets/improvements/wrapper-frontmatter.png +0 -0
  80. package/docs/assets/improvements/wrapper-init-directive.png +0 -0
  81. package/docs/assets/improvements/wrapper-leading-comment.png +0 -0
  82. package/docs/assets/style-cookbook/mid-century-report.png +0 -0
  83. package/docs/assets/style-cookbook/star-chart-atlas.png +0 -0
  84. package/docs/assets/style-cookbook/transit-route-map.png +0 -0
  85. package/docs/contributing/adding-diagram-types.md +0 -144
  86. package/docs/contributing/diagram-family-citizenship.matrix.json +0 -2127
  87. package/docs/contributing/diagram-family-citizenship.md +0 -82
  88. package/docs/contributing/harvesting-upstream-tests.md +0 -126
  89. package/docs/contributing/lessons-learned.md +0 -276
  90. package/docs/contributing/releasing.md +0 -82
  91. package/docs/contributing/visual-review-evidence.md +0 -53
  92. package/docs/design/families/README.md +0 -26
  93. package/docs/design/families/architecture-beta.md +0 -48
  94. package/docs/design/families/flowchart-parser-conformance.md +0 -22
  95. package/docs/design/families/gantt-research.md +0 -137
  96. package/docs/design/families/gantt.md +0 -329
  97. package/docs/design/families/journey-agentic-cards-before.png +0 -0
  98. package/docs/design/families/journey-experience-curve-implementation.png +0 -0
  99. package/docs/design/families/journey-mermaid-classic-implementation.png +0 -0
  100. package/docs/design/families/journey-mermaid-classic-prototype.png +0 -0
  101. package/docs/design/families/journey-mermaid-classic-prototype.svg +0 -163
  102. package/docs/design/families/journey-mermaid-classic-prototype.svg.png +0 -0
  103. package/docs/design/families/journey-migration-parity.md +0 -439
  104. package/docs/design/families/journey-section-overlap-after.png +0 -0
  105. package/docs/design/families/journey-section-overlap-before.png +0 -0
  106. package/docs/design/families/journey-usage-research.md +0 -423
  107. package/docs/design/families/journey.md +0 -153
  108. package/docs/design/families/xychart.md +0 -85
  109. package/docs/design/system/README.md +0 -45
  110. package/docs/design/system/abstraction-audit.md +0 -224
  111. package/docs/design/system/abstraction-recommendations.md +0 -655
  112. package/docs/design/system/architecture.mmd +0 -29
  113. package/docs/design/system/architecture.svg +0 -137
  114. package/docs/design/system/layout-guarantees-and-robustness.md +0 -351
  115. package/docs/design/system/layout-rubric.md +0 -138
  116. package/docs/design/system/route-contracts.md +0 -848
  117. package/docs/design/system/source-preservation-ladder.md +0 -52
  118. package/docs/design/system/styles-rollout.md +0 -216
  119. package/docs/design/system/ugly-layouts.md +0 -119
  120. package/docs/layout-characterization/README.md +0 -245
  121. package/docs/layout-characterization/contact-sheet-families.md +0 -498
  122. package/docs/layout-characterization/contact-sheet.md +0 -638
  123. package/docs/layout-characterization/properties.md +0 -281
  124. package/docs/layout-characterization/visual-quality.md +0 -160
  125. package/docs/layout-characterization/visual-snapshots/architecture.svg +0 -79
  126. package/docs/layout-characterization/visual-snapshots/class.svg +0 -60
  127. package/docs/layout-characterization/visual-snapshots/er.svg +0 -61
  128. package/docs/layout-characterization/visual-snapshots/flowchart.svg +0 -58
  129. package/docs/layout-characterization/visual-snapshots/gantt.svg +0 -52
  130. package/docs/layout-characterization/visual-snapshots/journey.svg +0 -98
  131. package/docs/layout-characterization/visual-snapshots/pie.svg +0 -36
  132. package/docs/layout-characterization/visual-snapshots/quadrant.svg +0 -47
  133. package/docs/layout-characterization/visual-snapshots/sequence.svg +0 -59
  134. package/docs/layout-characterization/visual-snapshots/state.svg +0 -52
  135. package/docs/layout-characterization/visual-snapshots/timeline.svg +0 -64
  136. package/docs/layout-characterization/visual-snapshots/xychart.svg +0 -97
  137. package/docs/project/divergences.md +0 -98
  138. package/docs/project/dogfooding-docs-strategy.md +0 -66
  139. package/docs/project/hosted-mcp-cloudflare-plan.md +0 -338
  140. package/docs/project/lessons-learned.md +0 -1019
  141. package/docs/project/remove-role-styling-plan.md +0 -176
  142. package/examples/agent-improve-auth-flow.ts +0 -206
  143. package/examples/agent-loop.ts +0 -53
  144. package/examples/mcp-vs-cli-complex-diagrams.ts +0 -133
  145. package/llms.txt +0 -104
  146. package/skill-evals/README.md +0 -77
  147. package/skill-evals/fixtures/architecture-cache/input.mmd +0 -5
  148. package/skill-evals/fixtures/class-payment/input.mmd +0 -4
  149. package/skill-evals/fixtures/er-orders/input.mmd +0 -10
  150. package/skill-evals/fixtures/flowchart-cache/input.mmd +0 -3
  151. package/skill-evals/fixtures/gantt-release/input.mmd +0 -7
  152. package/skill-evals/fixtures/journey-checkout/input.mmd +0 -6
  153. package/skill-evals/fixtures/pie-exports/input.mmd +0 -5
  154. package/skill-evals/fixtures/quadrant-priorities/input.mmd +0 -11
  155. package/skill-evals/fixtures/sequence-retry/input.mmd +0 -8
  156. package/skill-evals/fixtures/state-lifecycle/input.mmd +0 -5
  157. package/skill-evals/fixtures/timeline-release/input.mmd +0 -6
  158. package/skill-evals/fixtures/xychart-growth/input.mmd +0 -5
  159. package/skill-evals/shared-benchmark.json +0 -510
  160. package/src/agent/analyze.ts +0 -157
  161. package/src/agent/apply.ts +0 -146
  162. package/src/agent/architecture-body.ts +0 -458
  163. package/src/agent/certificates.ts +0 -110
  164. package/src/agent/class-body.ts +0 -319
  165. package/src/agent/core.ts +0 -207
  166. package/src/agent/create.ts +0 -106
  167. package/src/agent/describe.ts +0 -368
  168. package/src/agent/er-body.ts +0 -224
  169. package/src/agent/facts.ts +0 -360
  170. package/src/agent/families-builtin.ts +0 -920
  171. package/src/agent/families.ts +0 -232
  172. package/src/agent/family-layouts.ts +0 -832
  173. package/src/agent/flowchart-body.ts +0 -372
  174. package/src/agent/flowchart-unsupported.ts +0 -243
  175. package/src/agent/font-coverage.ts +0 -288
  176. package/src/agent/gantt-body.ts +0 -495
  177. package/src/agent/index.ts +0 -10
  178. package/src/agent/journey-body.ts +0 -374
  179. package/src/agent/label-metrics.ts +0 -28
  180. package/src/agent/layout-to-rendered.ts +0 -63
  181. package/src/agent/mutate.ts +0 -97
  182. package/src/agent/mutation-ops.ts +0 -51
  183. package/src/agent/op-schema.ts +0 -443
  184. package/src/agent/parse.ts +0 -223
  185. package/src/agent/pie-body.ts +0 -270
  186. package/src/agent/png.ts +0 -171
  187. package/src/agent/quadrant-body.ts +0 -336
  188. package/src/agent/quality.ts +0 -363
  189. package/src/agent/readability-audit.ts +0 -92
  190. package/src/agent/rendered-route-audit.ts +0 -61
  191. package/src/agent/sequence-body.ts +0 -345
  192. package/src/agent/serialize.ts +0 -214
  193. package/src/agent/state-body.ts +0 -589
  194. package/src/agent/structural-count.ts +0 -150
  195. package/src/agent/timeline-body.ts +0 -280
  196. package/src/agent/trace-log.ts +0 -43
  197. package/src/agent/types.ts +0 -995
  198. package/src/agent/verify.ts +0 -603
  199. package/src/agent/xychart-body.ts +0 -472
  200. package/src/architecture/config.ts +0 -282
  201. package/src/architecture/layout.ts +0 -496
  202. package/src/architecture/parser.ts +0 -375
  203. package/src/architecture/renderer.ts +0 -611
  204. package/src/architecture/types.ts +0 -113
  205. package/src/ascii/ansi.ts +0 -423
  206. package/src/ascii/architecture.ts +0 -210
  207. package/src/ascii/canvas.ts +0 -443
  208. package/src/ascii/class-diagram.ts +0 -697
  209. package/src/ascii/converter.ts +0 -475
  210. package/src/ascii/draw.ts +0 -1693
  211. package/src/ascii/edge-bundling.ts +0 -328
  212. package/src/ascii/edge-routing.ts +0 -330
  213. package/src/ascii/er-diagram.ts +0 -439
  214. package/src/ascii/gantt.ts +0 -276
  215. package/src/ascii/grid.ts +0 -747
  216. package/src/ascii/index.ts +0 -196
  217. package/src/ascii/journey.ts +0 -169
  218. package/src/ascii/meta.ts +0 -244
  219. package/src/ascii/multiline-utils.ts +0 -80
  220. package/src/ascii/pathfinder.ts +0 -301
  221. package/src/ascii/pie.ts +0 -67
  222. package/src/ascii/quadrant.ts +0 -158
  223. package/src/ascii/reverse.ts +0 -216
  224. package/src/ascii/sequence.ts +0 -499
  225. package/src/ascii/shapes/circle.ts +0 -27
  226. package/src/ascii/shapes/corners.ts +0 -143
  227. package/src/ascii/shapes/diamond.ts +0 -27
  228. package/src/ascii/shapes/hexagon.ts +0 -27
  229. package/src/ascii/shapes/index.ts +0 -106
  230. package/src/ascii/shapes/rectangle.ts +0 -168
  231. package/src/ascii/shapes/rounded.ts +0 -27
  232. package/src/ascii/shapes/special.ts +0 -332
  233. package/src/ascii/shapes/stadium.ts +0 -110
  234. package/src/ascii/shapes/state.ts +0 -192
  235. package/src/ascii/shapes/types.ts +0 -73
  236. package/src/ascii/timeline.ts +0 -141
  237. package/src/ascii/types.ts +0 -312
  238. package/src/ascii/validate.ts +0 -120
  239. package/src/ascii/width.ts +0 -81
  240. package/src/ascii/wrap.ts +0 -131
  241. package/src/ascii/xychart.ts +0 -919
  242. package/src/browser.ts +0 -29
  243. package/src/class/layout.ts +0 -240
  244. package/src/class/parser.ts +0 -332
  245. package/src/class/renderer.ts +0 -638
  246. package/src/class/types.ts +0 -129
  247. package/src/cli/agent-instructions.ts +0 -1
  248. package/src/cli/am-bin.ts +0 -6
  249. package/src/cli/exit-codes.ts +0 -18
  250. package/src/cli/index.ts +0 -1132
  251. package/src/cli/init-agent.ts +0 -147
  252. package/src/cli/run-entrypoint.ts +0 -10
  253. package/src/color-resolver.ts +0 -152
  254. package/src/elk-instance.ts +0 -114
  255. package/src/er/layout.ts +0 -187
  256. package/src/er/parser.ts +0 -226
  257. package/src/er/renderer.ts +0 -792
  258. package/src/er/types.ts +0 -99
  259. package/src/family-rubric.ts +0 -425
  260. package/src/font-manifest.ts +0 -31
  261. package/src/gantt/layout.ts +0 -365
  262. package/src/gantt/parser.ts +0 -282
  263. package/src/gantt/pipeline.ts +0 -27
  264. package/src/gantt/renderer.ts +0 -414
  265. package/src/gantt/schedule.ts +0 -513
  266. package/src/gantt/types.ts +0 -255
  267. package/src/index.ts +0 -368
  268. package/src/journey/layout.ts +0 -554
  269. package/src/journey/parse-core.ts +0 -251
  270. package/src/journey/parser.ts +0 -101
  271. package/src/journey/renderer.ts +0 -917
  272. package/src/journey/types.ts +0 -144
  273. package/src/layout/geometry.ts +0 -182
  274. package/src/layout/pass.ts +0 -142
  275. package/src/layout/passes/index.ts +0 -3916
  276. package/src/layout-engine.ts +0 -1565
  277. package/src/layout-rubric.ts +0 -659
  278. package/src/mcp/artifacts.ts +0 -137
  279. package/src/mcp/dynamic-harness.ts +0 -31
  280. package/src/mcp/dynamic-modules.d.ts +0 -7
  281. package/src/mcp/facade.ts +0 -608
  282. package/src/mcp/harness-runtime.ts +0 -203
  283. package/src/mcp/hosted-server.ts +0 -468
  284. package/src/mcp/mcp-bin.ts +0 -3
  285. package/src/mcp/mcp-cli.ts +0 -82
  286. package/src/mcp/protocol.ts +0 -13
  287. package/src/mcp/sandbox.ts +0 -195
  288. package/src/mcp/sdk-decl.ts +0 -391
  289. package/src/mcp/server.ts +0 -362
  290. package/src/mcp/tool-surface.ts +0 -146
  291. package/src/mermaid-source.ts +0 -655
  292. package/src/multiline-utils.ts +0 -228
  293. package/src/parser.ts +0 -1031
  294. package/src/pie/layout.ts +0 -168
  295. package/src/pie/parser.ts +0 -136
  296. package/src/pie/renderer.ts +0 -234
  297. package/src/pie/types.ts +0 -82
  298. package/src/quadrant/layout.ts +0 -191
  299. package/src/quadrant/parser.ts +0 -183
  300. package/src/quadrant/renderer.ts +0 -322
  301. package/src/quadrant/types.ts +0 -113
  302. package/src/render-family-hooks.ts +0 -244
  303. package/src/renderer.ts +0 -1099
  304. package/src/route-contracts.ts +0 -2723
  305. package/src/scene/backend.ts +0 -76
  306. package/src/scene/fidelity.ts +0 -183
  307. package/src/scene/hybrid-backend.ts +0 -174
  308. package/src/scene/ir.ts +0 -181
  309. package/src/scene/marks.ts +0 -127
  310. package/src/scene/rough-backend.ts +0 -378
  311. package/src/scene/seed.ts +0 -46
  312. package/src/scene/style-registry.ts +0 -556
  313. package/src/sequence/layout.ts +0 -456
  314. package/src/sequence/parser.ts +0 -255
  315. package/src/sequence/renderer.ts +0 -604
  316. package/src/sequence/types.ts +0 -154
  317. package/src/shape-clipping.ts +0 -351
  318. package/src/shared/accessibility-directives.ts +0 -22
  319. package/src/shared/batched.ts +0 -61
  320. package/src/shared/color-math.ts +0 -93
  321. package/src/shared/svg-a11y.ts +0 -29
  322. package/src/shared/syntax-error.ts +0 -17
  323. package/src/shared/unicode-ranges.ts +0 -135
  324. package/src/styles.ts +0 -318
  325. package/src/svg-paths.ts +0 -28
  326. package/src/text-metrics.ts +0 -248
  327. package/src/theme.ts +0 -710
  328. package/src/timeline/layout.ts +0 -381
  329. package/src/timeline/parser.ts +0 -215
  330. package/src/timeline/renderer.ts +0 -538
  331. package/src/timeline/types.ts +0 -105
  332. package/src/types.ts +0 -462
  333. package/src/website-worker-modules.d.ts +0 -18
  334. package/src/xychart/axis-utils.ts +0 -109
  335. package/src/xychart/colors.ts +0 -124
  336. package/src/xychart/config.ts +0 -88
  337. package/src/xychart/layout.ts +0 -656
  338. package/src/xychart/parser.ts +0 -504
  339. package/src/xychart/renderer.ts +0 -640
  340. package/src/xychart/types.ts +0 -282
@@ -1,1565 +0,0 @@
1
- /**
2
- * Layout engine for Agentic Mermaid (ELK.js based).
3
- *
4
- * Converts MermaidGraph to ELK's JSON format, runs layout, and converts
5
- * the result back to PositionedGraph. This is the core layout engine used
6
- * by all graph-based diagram types (flowcharts, state, ER, class).
7
- *
8
- * ELK (Eclipse Layout Kernel) features:
9
- * - Native orthogonal edge routing (no post-processing needed)
10
- * - Proper handling of compound nodes (subgraphs)
11
- * - Support for disconnected graphs
12
- * - Direction overrides per subgraph
13
- * - Sophisticated algorithms for complex graphs
14
- *
15
- * Uses elk.bundled.js (pure synchronous JS, no WASM/Workers).
16
- * Safe for Electron, Node, and browser environments.
17
- */
18
-
19
- import type { ElkNode, ElkExtendedEdge, LayoutOptions } from 'elkjs'
20
- import type {
21
- MermaidGraph,
22
- MermaidSubgraph,
23
- MermaidEdge,
24
- MermaidNode,
25
- Direction,
26
- PositionedGraph,
27
- PositionedNode,
28
- PositionedEdge,
29
- PositionedGroup,
30
- Point,
31
- RenderOptions,
32
- NodeShape,
33
- DiamondFacet,
34
- RouteClass,
35
- } from './types.ts'
36
- import { ARROW_HEAD, FLOWCHART_DOTTED_DASH, applyTextTransform, resolveRenderStyle } from './styles.ts'
37
- import type { ResolvedRenderStyle } from './styles.ts'
38
- import type { InternalStyleFace } from './scene/style-registry.ts'
39
- import { measureMultilineText } from './text-metrics.ts'
40
- import { elkLayoutSync } from './elk-instance.ts'
41
- import { clipEdgeToShape } from './shape-clipping.ts'
42
- import { onShapeOutline, assessLayout, hardViolations } from './layout-rubric.ts'
43
- import { applyRouteContracts, classifyRoutes, diamondFacetPorts, labelRect, PORT_EXACT, repairLabelsOffOwnRoute, repairLabelsOnSharedTrunks, separateEdgeLabelPills, shapePorts, simplifyPolyline } from './route-contracts.ts'
44
- import type { LabelMetricsStyle } from './route-contracts.ts'
45
- import { resolveEdgeInlineStyle, resolveNodeInlineStyle } from './color-resolver.ts'
46
- import { runPipeline } from './layout/pass.ts'
47
- import type { LayoutPass, PassContextBase } from './layout/pass.ts'
48
- import {
49
- DEFAULTS,
50
- flattenGroupBounds,
51
- polylineLength,
52
- pointAtPathDistance,
53
- calculatePathMidpoint,
54
- layoutFlow,
55
- positionedNodeCenter,
56
- nodeMainStart,
57
- nodeCrossStart,
58
- nodeCrossSize,
59
- nodeMainSize,
60
- nodeCrossCenter,
61
- nodeMainCenter,
62
- rectsOverlap,
63
- findSubgraph,
64
- layoutEnvFlag,
65
- } from './layout/geometry.ts'
66
- import type { MarginInfo } from './layout/geometry.ts'
67
- import {
68
- translateGeometryToNonNegativeOrigin,
69
- extractEdgesRecursively,
70
- alignLayerNodes,
71
- equalizePeerNodeDimensions,
72
- reanchorOffOutlineEndpoints,
73
- rerouteEdgesThroughNodes,
74
- alignForkRejoinPeerCenters,
75
- alignPortLanes,
76
- alignLabeledSourcePort,
77
- centerPeerBarycenters,
78
- honorLinkRankDistance,
79
- bundleEdgePaths,
80
- markCorankFanInBundles,
81
- applySymmetricFanoutEmissions,
82
- applySymmetricParallelEdgeLanes,
83
- applyParallelDuplicateLanes,
84
- collapseTinyBundledHitches,
85
- reassignBundledSiblingLabels,
86
- } from './layout/passes/index.ts'
87
- // Re-export the two passes that were part of the public surface before relocation.
88
- export { alignLayerNodes, orthogonalizeEdgePoints } from './layout/passes/index.ts'
89
-
90
- interface LayoutEngineOptions extends RenderOptions {
91
- /** @internal Preserve direct child order in compound nodes for projected families. */
92
- preserveSubgraphChildOrder?: boolean
93
- /** @internal Precomputed built-in style face for projected family layout. */
94
- styleFace?: InternalStyleFace
95
- }
96
-
97
- type ElkConversionOptions = Required<Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>> &
98
- Pick<LayoutEngineOptions, 'preserveSubgraphChildOrder'>
99
-
100
- // ============================================================================
101
- // Layout options
102
- // ============================================================================
103
-
104
- /** Default render options (layout-only) */
105
- /** Convert Mermaid direction to ELK direction */
106
- function directionToElk(dir: MermaidGraph['direction']): string {
107
- switch (dir) {
108
- case 'LR': return 'RIGHT'
109
- case 'RL': return 'LEFT'
110
- case 'BT': return 'UP'
111
- case 'TD':
112
- case 'TB':
113
- default: return 'DOWN'
114
- }
115
- }
116
-
117
- /**
118
- * ELK's model-order options are useful for keeping feedback-loop diagrams
119
- * readable, but raw Mermaid node insertion order is target-biased: `A --> C`
120
- * creates `C` before a later `B --> C`, which can make the `B --> C` edge run
121
- * backwards. Build a source-aware, cycle-tolerant model order from edges:
122
- * add each source-before-target constraint unless it would create a cycle, then
123
- * topologically sort with original order as the tie-breaker.
124
- */
125
- function sourceAwareNodeOrder(nodeIds: string[], edges: Array<Pick<MermaidEdge, 'source' | 'target'>>): string[] {
126
- const ids = new Set(nodeIds)
127
- const original = new Map(nodeIds.map((id, i) => [id, i]))
128
- const outgoing = new Map(nodeIds.map(id => [id, new Set<string>()]))
129
- const indegree = new Map(nodeIds.map(id => [id, 0]))
130
-
131
- const reaches = (from: string, to: string): boolean => {
132
- const seen = new Set<string>()
133
- const stack = [from]
134
- while (stack.length > 0) {
135
- const id = stack.pop()!
136
- if (id === to) return true
137
- if (seen.has(id)) continue
138
- seen.add(id)
139
- for (const next of outgoing.get(id) ?? []) stack.push(next)
140
- }
141
- return false
142
- }
143
-
144
- for (const edge of edges) {
145
- if (edge.source === edge.target || !ids.has(edge.source) || !ids.has(edge.target)) continue
146
- const from = edge.source
147
- const to = edge.target
148
- const set = outgoing.get(from)!
149
- if (set.has(to)) continue
150
- if (reaches(to, from)) continue // feedback edge; preserve it as a back edge
151
- set.add(to)
152
- indegree.set(to, (indegree.get(to) ?? 0) + 1)
153
- }
154
-
155
- const orderByOriginal = (a: string, b: string): number => (original.get(a) ?? 0) - (original.get(b) ?? 0)
156
- const ready = nodeIds.filter(id => (indegree.get(id) ?? 0) === 0).sort(orderByOriginal)
157
- const out: string[] = []
158
- while (ready.length > 0) {
159
- const id = ready.shift()!
160
- out.push(id)
161
- for (const next of Array.from(outgoing.get(id) ?? []).sort(orderByOriginal)) {
162
- const nextDegree = (indegree.get(next) ?? 0) - 1
163
- indegree.set(next, nextDegree)
164
- if (nextDegree === 0) {
165
- ready.push(next)
166
- ready.sort(orderByOriginal)
167
- }
168
- }
169
- }
170
-
171
- if (out.length < nodeIds.length) {
172
- const emitted = new Set(out)
173
- out.push(...nodeIds.filter(id => !emitted.has(id)).sort(orderByOriginal))
174
- }
175
- return out
176
- }
177
-
178
- // ============================================================================
179
- // Node sizing (same logic as Dagre adapter)
180
- // ============================================================================
181
-
182
- function estimateNodeSize(id: string, label: string, shape: string, style: ResolvedRenderStyle): { width: number; height: number } {
183
- void id
184
- const metrics = measureMultilineText(applyTextTransform(label, style.nodeTextTransform), style.nodeLabelFontSize, style.nodeLabelFontWeight)
185
-
186
- let width = metrics.width + style.nodePaddingX * 2
187
- let height = metrics.height + style.nodePaddingY * 2
188
-
189
- if (shape === 'service') {
190
- width = Math.max(width + 34, 120)
191
- height = Math.max(height + 6, 48)
192
- }
193
-
194
- if (shape === 'diamond') {
195
- const side = Math.max(width, height) + style.diamondExtraPadding
196
- width = side
197
- height = side
198
- }
199
-
200
- if (shape === 'circle' || shape === 'doublecircle') {
201
- const diameter = Math.ceil(Math.sqrt(width * width + height * height)) + 8
202
- width = shape === 'doublecircle' ? diameter + 12 : diameter
203
- height = width
204
- }
205
-
206
- if (shape === 'hexagon') {
207
- width += style.nodePaddingX
208
- }
209
-
210
- if (shape === 'trapezoid' || shape === 'trapezoid-alt' || shape === 'lean-r' || shape === 'lean-l') {
211
- width += style.nodePaddingX
212
- }
213
-
214
- if (shape === 'asymmetric') {
215
- width += 12
216
- }
217
-
218
- if (shape === 'cylinder') {
219
- height += 14
220
- }
221
-
222
- if (shape === 'state-start' || shape === 'state-end') {
223
- return { width: 28, height: 28 }
224
- }
225
-
226
- width = Math.max(width, 60)
227
- height = Math.max(height, 36)
228
-
229
- return { width, height }
230
- }
231
-
232
- // ============================================================================
233
- // Graph conversion: MermaidGraph → ELK JSON
234
- // ============================================================================
235
-
236
- interface ElkGraphNode extends ElkNode {
237
- children?: ElkGraphNode[]
238
- edges?: ElkExtendedEdge[]
239
- }
240
-
241
- /**
242
- * Tracks port-to-edge mappings for hierarchical port edges.
243
- * Used to combine external and internal edge sections during extraction.
244
- */
245
- interface HierarchicalEdgeInfo {
246
- originalIndex: number
247
- externalEdgeId: string
248
- internalEdgeId: string
249
- subgraphId: string
250
- direction: 'incoming' | 'outgoing'
251
- }
252
-
253
- interface CrossHierarchyEdgeInfo {
254
- index: number
255
- edge: MermaidEdge
256
- sourceSubgraph: string | undefined
257
- targetSubgraph: string | undefined
258
- /** Lowest common compound that should host the external segment; undefined means root. */
259
- hostSubgraph?: string
260
- }
261
-
262
- interface RoutePortHint {
263
- nodeId: string
264
- portId: string
265
- side: 'N' | 'E' | 'S' | 'W'
266
- edgeIndex: number
267
- sourceId: string
268
- targetId: string
269
- endpoint: 'source' | 'target'
270
- slotIndex: number
271
- routeClass: RouteClass
272
- labeled: boolean
273
- constraint: 'FIXED_SIDE' | 'FIXED_ORDER'
274
- orderReason?: 'primary-duplicate-face'
275
- }
276
-
277
- interface RoutePortRelaxation {
278
- edgeIndex: number
279
- routeClass: RouteClass
280
- reason: 'self-loop' | 'missing-node' | 'subgraph-endpoint' | 'direction-override-node' | 'unsupported-route-class' | 'unsupported-shape' | 'unsafe-non-primary-incident' | 'labeled-reciprocal-primary'
281
- }
282
-
283
- interface RoutePortHints {
284
- byEndpoint: Map<string, RoutePortHint>
285
- byNode: Map<string, RoutePortHint[]>
286
- relaxations: RoutePortRelaxation[]
287
- }
288
-
289
- /** Dimensions of the layout-only ELK label injected to balance a fan-in rank. */
290
- interface BalancingLabel {
291
- width: number
292
- height: number
293
- }
294
-
295
- /**
296
- * Co-rank mixed-label fan-ins (default ON; disable with APL_NO_CORANK_FANIN).
297
- *
298
- * A fan-in hub fed by both a LABELED edge and an UNLABELED edge is asymmetric:
299
- * ELK reserves an inline-label dummy rank for the labeled edge only, so that
300
- * edge's source lands one rank EARLIER than the unlabeled sibling's source
301
- * (e.g. `A -->|x| B` with `B2 --> B` puts A one layer before B2). The
302
- * peer-barycenter centering can't square up a fan-in whose sources sit on
303
- * different ranks.
304
- *
305
- * The fix balances the rank span: every unlabeled incident edge of a mixed
306
- * hub gets a layout-only ELK label (one non-empty space, sized to the WIDEST
307
- * labeled sibling so the reserved cell matches) — ELK then allocates the same
308
- * dummy rank for it and all the hub's sources co-rank. The label is never
309
- * rendered: edge readback keys off the Mermaid edge's own `label`, which stays
310
- * undefined for these edges (see extractEdgesRecursively), so this only moves
311
- * ranks, never pixels of text. With the sources co-ranked, centerPeerBarycenters
312
- * squares up the hub and the spokes converge as symmetric doglegs; that bend is
313
- * justified (the convergence is structurally necessary) so the spokes are marked
314
- * bundle-owned downstream — see markCorankFanInBundles, findRouteHitches's
315
- * 'bundle' skip, and the layout-rubric justified-bend exemption.
316
- *
317
- * Returns a map from edge index -> the balancing label's dimensions, empty when
318
- * disabled (APL_NO_CORANK_FANIN) or no mixed fan-in exists. Deterministic:
319
- * dimensions derive only from the sibling labels' measured text.
320
- */
321
- function corankFanInBalancingLabels(
322
- graph: MermaidGraph,
323
- style: ResolvedRenderStyle,
324
- ): Map<number, BalancingLabel> {
325
- const balancing = new Map<number, BalancingLabel>()
326
- if (layoutEnvFlag('APL_NO_CORANK_FANIN')) return balancing
327
-
328
- // Group edge indices by target node (the potential fan-in hub). Self-loops
329
- // never co-rank a hub and are skipped.
330
- const incomingByTarget = new Map<string, number[]>()
331
- for (let i = 0; i < graph.edges.length; i++) {
332
- const edge = graph.edges[i]!
333
- if (edge.source === edge.target) continue
334
- if (!incomingByTarget.has(edge.target)) incomingByTarget.set(edge.target, [])
335
- incomingByTarget.get(edge.target)!.push(i)
336
- }
337
-
338
- for (const [hubId, indices] of incomingByTarget) {
339
- if (indices.length < 2) continue
340
- const labeled = indices.filter(i => !!graph.edges[i]!.label)
341
- const unlabeled = indices.filter(i => !graph.edges[i]!.label)
342
- // Only a MIXED hub (some labeled, some not) is desynced; a uniformly
343
- // labeled or uniformly unlabeled fan-in already co-ranks.
344
- if (labeled.length === 0 || unlabeled.length === 0) continue
345
- // The HUB may be any PORT_EXACT shape (a DECISION diamond, round, stadium, …):
346
- // co-ranking its mixed-label sources pre-ELK is what lets centerPeerBarycenters
347
- // centre such a hub and markCorankFanInBundles converge + centre the labels. The
348
- // UNLABELED-only alignPortLanes fan-in centering never fires on a MIXED-label
349
- // fan-in, so this does not fight it.
350
- // The SOURCES stay rectangle-only, though: a diamond (or other sharp/curved)
351
- // SOURCE emitting a single labelled line has its own exact-vertex exit contract
352
- // (issue #26 WS3 / alignPortLanes) that a co-rank bundle re-route would override.
353
- // Squaring up the hub never requires re-emitting the sources, so leave them.
354
- const hubShape = graph.nodes.get(hubId)?.shape
355
- if (hubShape === undefined || !PORT_EXACT.has(hubShape)) continue
356
- const isRect = (id: string): boolean => graph.nodes.get(id)?.shape === 'rectangle'
357
- if (!indices.every(i => isRect(graph.edges[i]!.source))) continue
358
-
359
- // Size the balancing cell to the WIDEST labeled sibling so every unlabeled
360
- // edge reserves at least as much rank space as the labeled ones — matching
361
- // the existing inline-label dimensions (measured width + 8, height + 6).
362
- let width = 0
363
- let height = 0
364
- for (const i of labeled) {
365
- const label = applyTextTransform(graph.edges[i]!.label!, style.edgeTextTransform)
366
- const metrics = measureMultilineText(label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
367
- width = Math.max(width, metrics.width + 8)
368
- height = Math.max(height, metrics.height + 6)
369
- }
370
- for (const i of unlabeled) balancing.set(i, { width, height })
371
- }
372
-
373
- return balancing
374
- }
375
-
376
- /** Build the layout-only inline label ELK uses to reserve a balancing rank. */
377
- function balancingElkLabel(dims: BalancingLabel): NonNullable<ElkExtendedEdge['labels']>[number] {
378
- // A single space is non-empty text, so ELK allocates a label dummy (an empty
379
- // string is treated as no label and reserves nothing). Inline + CENTER mirror
380
- // the real edge-label placement so the reserved rank lines up.
381
- return {
382
- text: ' ',
383
- width: dims.width,
384
- height: dims.height,
385
- layoutOptions: {
386
- 'elk.edgeLabels.inline': 'true',
387
- 'elk.edgeLabels.placement': 'CENTER',
388
- },
389
- }
390
- }
391
-
392
- /**
393
- * Convert a MermaidGraph to ELK's nested JSON input format.
394
- *
395
- * Uses SEPARATE hierarchy handling for proper subgraph direction override support.
396
- * Cross-hierarchy edges use hierarchical ports to connect external and internal sections.
397
- */
398
- function mermaidToElk(
399
- graph: MermaidGraph,
400
- opts: ElkConversionOptions,
401
- style: ResolvedRenderStyle,
402
- ): ElkGraphNode {
403
- // Collect all node IDs that belong to subgraphs
404
- const subgraphNodeIds = new Set<string>()
405
- const subgraphIds = new Set<string>()
406
- for (const sg of graph.subgraphs) {
407
- subgraphIds.add(sg.id)
408
- collectSubgraphNodeIds(sg, subgraphNodeIds, subgraphIds)
409
- }
410
-
411
- // Layout-only balancing labels that co-rank mixed-label fan-ins (default ON;
412
- // empty when APL_NO_CORANK_FANIN is set). Applied at every label-injection
413
- // site below so the rank balance holds wherever the edge is hosted (root,
414
- // subgraph-internal, cross-hierarchy).
415
- const balancingLabels = corankFanInBalancingLabels(graph, style)
416
-
417
- // Build node-to-subgraph mapping for edge distribution
418
- const nodeToSubgraph = buildNodeToSubgraphMap(graph.subgraphs)
419
- const nodeToRootSubgraph = buildNodeToRootSubgraphMap(graph.subgraphs)
420
- const nodeToSubgraphAncestors = buildNodeSubgraphAncestorsMap(graph.subgraphs)
421
- const subgraphToParent = buildSubgraphParentMap(graph.subgraphs)
422
- const subgraphAncestors = buildSubgraphAncestorsMap(graph.subgraphs)
423
- const endpointSubgraph = (id: string) => nodeToSubgraph.get(id) ?? subgraphToParent.get(id)
424
- // For a subgraph-id endpoint, use the containing ancestry (excluding the
425
- // subgraph itself): an edge to `Pipeline` targets the container box, not a
426
- // node inside Pipeline.
427
- const endpointAncestors = (id: string) =>
428
- nodeToSubgraphAncestors.get(id) ?? subgraphAncestors.get(id)?.slice(0, -1) ?? []
429
-
430
- // Determine if we need SEPARATE hierarchy handling
431
- // We use SEPARATE when any subgraph has a direction override
432
- const hasDirectionOverride = graph.subgraphs.some(sg => sg.direction !== undefined)
433
-
434
- // Classify edges into three categories:
435
- // 1. Internal edges (both endpoints in same subgraph)
436
- // 2. Root-level edges (neither endpoint in a subgraph)
437
- // 3. Cross-hierarchy edges (endpoints in different levels)
438
- const edgesBySubgraph = new Map<string | null, Array<{ index: number; edge: typeof graph.edges[0] }>>()
439
- edgesBySubgraph.set(null, []) // Root-level edges
440
-
441
- // Track cross-hierarchy edges for hierarchical port creation
442
- const crossHierarchyEdges: CrossHierarchyEdgeInfo[] = []
443
-
444
- for (let i = 0; i < graph.edges.length; i++) {
445
- const edge = graph.edges[i]!
446
- const sourceSubgraph = endpointSubgraph(edge.source)
447
- const targetSubgraph = endpointSubgraph(edge.target)
448
-
449
- if (sourceSubgraph && sourceSubgraph === targetSubgraph) {
450
- // Internal edge: both endpoints in same subgraph
451
- if (!edgesBySubgraph.has(sourceSubgraph)) {
452
- edgesBySubgraph.set(sourceSubgraph, [])
453
- }
454
- edgesBySubgraph.get(sourceSubgraph)!.push({ index: i, edge })
455
- } else if (!sourceSubgraph && !targetSubgraph) {
456
- // Root-level edge: neither endpoint in a subgraph
457
- edgesBySubgraph.get(null)!.push({ index: i, edge })
458
- } else if (!hasDirectionOverride) {
459
- // INCLUDE_CHILDREN can route direct descendant edges without ports, but
460
- // ELK reports an edge's section in the coordinate space of its lowest
461
- // common compound. Store such edges on that compound so recursive
462
- // extraction adds the correct absolute offset (outer A -> inner B).
463
- const lca = deepestCommonAncestor(
464
- endpointAncestors(edge.source),
465
- endpointAncestors(edge.target),
466
- )
467
- if (lca) {
468
- if (!edgesBySubgraph.has(lca)) edgesBySubgraph.set(lca, [])
469
- edgesBySubgraph.get(lca)!.push({ index: i, edge })
470
- } else {
471
- edgesBySubgraph.get(null)!.push({ index: i, edge })
472
- }
473
- } else {
474
- // Cross-hierarchy edge: need hierarchical ports. When both endpoints
475
- // live under one compound, host the external port-to-port segment on
476
- // that lowest common compound so extraction receives the right offset.
477
- const hostSubgraph = deepestCommonAncestor(
478
- endpointAncestors(edge.source),
479
- endpointAncestors(edge.target),
480
- )
481
- crossHierarchyEdges.push({ index: i, edge, sourceSubgraph, targetSubgraph, hostSubgraph })
482
- }
483
- }
484
-
485
- // Build the root ELK graph
486
- const elkGraph: ElkGraphNode = {
487
- id: 'root',
488
- layoutOptions: {
489
- 'elk.algorithm': 'layered',
490
- 'elk.direction': directionToElk(graph.direction),
491
- 'elk.spacing.nodeNode': String(opts.nodeSpacing),
492
- 'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.layerSpacing),
493
- 'elk.spacing.edgeEdge': '12',
494
- 'elk.layered.spacing.edgeEdgeBetweenLayers': '12',
495
- 'elk.layered.spacing.edgeNodeBetweenLayers': '12',
496
- 'elk.padding': `[top=${opts.padding},left=${opts.padding},bottom=${opts.padding},right=${opts.padding}]`,
497
- 'elk.edgeRouting': 'ORTHOGONAL',
498
- 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
499
- 'elk.contentAlignment': 'H_CENTER V_CENTER',
500
- 'elk.layered.thoroughness': String(DEFAULTS.thoroughness),
501
- 'elk.layered.highDegreeNodes.treatment': 'true',
502
- 'elk.layered.highDegreeNodes.threshold': '8',
503
- 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONSTRAINT_LOCKING',
504
- // Mermaid source order is author intent for small flowcharts with feedback
505
- // loops. ELK's default greedy cycle breaker can rank a decision before the
506
- // login step in A->B->C plus C->B graphs; model-order cycle breaking keeps
507
- // the primary LR path readable and sends the feedback edge backwards.
508
- 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
509
- 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
510
- // Feedback edges route AROUND the nodes through outer channels (issue #25
511
- // §10), instead of competing with the forward edge for the same facing
512
- // side — which squeezed both lanes and the label into a corridor capped
513
- // by the node height. With this on, the forward lane of a reciprocal
514
- // pair is straight directly out of ELK, and the inline label dummy gets
515
- // reserved space ON the loop (dot's virtual-node doctrine; Gansner et
516
- // al. TSE 1993). The route-contract pass still collapses an unlabeled
517
- // loop onto a parallel back-lane when one proves clear.
518
- 'elk.layered.feedbackEdges': 'true',
519
- 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true',
520
- 'elk.layered.wrapping.strategy': 'OFF',
521
- // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
522
- // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
523
- 'elk.hierarchyHandling': hasDirectionOverride ? 'SEPARATE' : 'INCLUDE_CHILDREN',
524
- },
525
- children: [],
526
- edges: [],
527
- }
528
-
529
- // Track hierarchical ports per subgraph for cross-hierarchy edges
530
- const subgraphPorts = new Map<string, Array<{
531
- portId: string
532
- edgeIndex: number
533
- direction: 'incoming' | 'outgoing'
534
- internalNodeId: string
535
- }>>()
536
- const crossEdgesByHost = new Map<string | null, CrossHierarchyEdgeInfo[]>()
537
- crossEdgesByHost.set(null, [])
538
-
539
- // Process cross-hierarchy edges to create port entries and host maps.
540
- if (hasDirectionOverride) {
541
- for (const info of crossHierarchyEdges) {
542
- const { index, edge, sourceSubgraph, targetSubgraph, hostSubgraph } = info
543
- const hostKey = hostSubgraph ?? null
544
- if (!crossEdgesByHost.has(hostKey)) crossEdgesByHost.set(hostKey, [])
545
- crossEdgesByHost.get(hostKey)!.push(info)
546
-
547
- // Handle outgoing edges from nested source subgraphs. If the source's
548
- // own subgraph hosts the external segment, the node can be referenced
549
- // directly and no boundary port is needed.
550
- if (sourceSubgraph && sourceSubgraph !== hostSubgraph) {
551
- const portId = `${sourceSubgraph}_out_${index}`
552
- if (!subgraphPorts.has(sourceSubgraph)) {
553
- subgraphPorts.set(sourceSubgraph, [])
554
- }
555
- subgraphPorts.get(sourceSubgraph)!.push({
556
- portId,
557
- edgeIndex: index,
558
- direction: 'outgoing',
559
- internalNodeId: edge.source,
560
- })
561
- }
562
-
563
- // Handle incoming edges to nested target subgraphs. If the target's own
564
- // subgraph hosts the external segment, target the node directly.
565
- if (targetSubgraph && targetSubgraph !== hostSubgraph) {
566
- const portId = `${targetSubgraph}_in_${index}`
567
- if (!subgraphPorts.has(targetSubgraph)) {
568
- subgraphPorts.set(targetSubgraph, [])
569
- }
570
- subgraphPorts.get(targetSubgraph)!.push({
571
- portId,
572
- edgeIndex: index,
573
- direction: 'incoming',
574
- internalNodeId: edge.target,
575
- })
576
- }
577
- }
578
- }
579
-
580
- // Pre-layout route intent: expose the same semantic source/target sides used
581
- // by the certifying route-port allocator to ELK as fixed-side node ports.
582
- // This is deliberately a hint layer (ELK still chooses coordinates/order);
583
- // final certificates are still derived after clipping and route repair.
584
- const routePortHints = buildRoutePortHints(graph, subgraphIds)
585
-
586
- // Projected families such as architecture may rely on parser child order
587
- // inside compounds for stable boundary routing; root-level group-vs-node
588
- // siblings still use source-before-target constraints.
589
- const useSourceAwareChildOrder = !opts.preserveSubgraphChildOrder
590
-
591
- const topLevelSubgraphs = new Map(graph.subgraphs.map(sg => [sg.id, sg]))
592
- const rootOrder = rootChildOrder(graph, subgraphNodeIds, subgraphIds, nodeToRootSubgraph, Boolean(opts.preserveSubgraphChildOrder))
593
- for (const id of rootOrder) {
594
- const sg = topLevelSubgraphs.get(id)
595
- if (sg) {
596
- elkGraph.children!.push(subgraphToElk(sg, graph, opts, style, edgesBySubgraph, subgraphPorts, crossEdgesByHost, routePortHints, useSourceAwareChildOrder, balancingLabels))
597
- continue
598
- }
599
- const node = graph.nodes.get(id)
600
- if (node && !subgraphNodeIds.has(id) && !subgraphIds.has(id)) {
601
- elkGraph.children!.push(nodeToElkLeaf(id, node, style, routePortHints))
602
- }
603
- }
604
-
605
- // Add root-level edges
606
- for (const { index, edge } of edgesBySubgraph.get(null)!) {
607
- const elkEdge: ElkExtendedEdge = {
608
- id: `e${index}`,
609
- sources: [routePortHints.byEndpoint.get(endpointKey(index, 'source'))?.portId ?? edge.source],
610
- targets: [routePortHints.byEndpoint.get(endpointKey(index, 'target'))?.portId ?? edge.target],
611
- }
612
- if (edge.label && !layoutEnvFlag('APL_DECOUPLE_LABELS')) {
613
- const label = applyTextTransform(edge.label, style.edgeTextTransform)
614
- const metrics = measureMultilineText(label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
615
- elkEdge.labels = [{
616
- text: label,
617
- width: metrics.width + 8,
618
- height: metrics.height + 6,
619
- layoutOptions: {
620
- 'elk.edgeLabels.inline': 'true',
621
- 'elk.edgeLabels.placement': 'CENTER',
622
- },
623
- }]
624
- } else if (!edge.label && balancingLabels.has(index)) {
625
- elkEdge.labels = [balancingElkLabel(balancingLabels.get(index)!)]
626
- }
627
- elkGraph.edges!.push(elkEdge)
628
- }
629
-
630
- // Add root-hosted cross-hierarchy edges (using ports when SEPARATE, direct when INCLUDE_CHILDREN)
631
- for (const info of crossEdgesByHost.get(null) ?? []) {
632
- elkGraph.edges!.push(crossHierarchyElkEdge(info, style, balancingLabels))
633
- }
634
-
635
- return elkGraph
636
- }
637
-
638
- function endpointKey(edgeIndex: number, endpoint: 'source' | 'target'): string {
639
- return `${edgeIndex}:${endpoint}`
640
- }
641
-
642
- function directedSubgraphNodeIds(subgraphs: MermaidSubgraph[]): Set<string> {
643
- const out = new Set<string>()
644
- const visit = (sg: MermaidSubgraph, inheritedDirected: boolean) => {
645
- const directed = inheritedDirected || sg.direction !== undefined
646
- if (directed) for (const id of sg.nodeIds) out.add(id)
647
- for (const child of sg.children) visit(child, directed)
648
- }
649
- for (const sg of subgraphs) visit(sg, false)
650
- return out
651
- }
652
-
653
- function axisSides(direction: Direction): { source: 'N' | 'E' | 'S' | 'W'; target: 'N' | 'E' | 'S' | 'W' } {
654
- switch (direction) {
655
- case 'RL': return { source: 'W', target: 'E' }
656
- case 'BT': return { source: 'N', target: 'S' }
657
- case 'TD':
658
- case 'TB': return { source: 'S', target: 'N' }
659
- case 'LR':
660
- default: return { source: 'E', target: 'W' }
661
- }
662
- }
663
-
664
- function routeHintSides(direction: Direction, routeClass: ReturnType<typeof classifyRoutes>[number]): { source: 'N' | 'E' | 'S' | 'W'; target: 'N' | 'E' | 'S' | 'W' } {
665
- const sides = axisSides(direction)
666
- if (routeClass === 'feedback') return { source: sides.target, target: sides.source }
667
- return sides
668
- }
669
-
670
- function supportsPreLayoutFeedbackPorts(node: MermaidNode | undefined): boolean {
671
- return node?.shape === 'rectangle' || node?.shape === 'diamond'
672
- }
673
-
674
- function supportsPreLayoutFixedOrderPorts(node: MermaidNode | undefined): boolean {
675
- return node?.shape === 'rectangle'
676
- }
677
-
678
- function certifiesPrimaryDuplicateFaceOrder(entries: RoutePortHint[]): boolean {
679
- if (entries.length < 2) return false
680
- if (!entries.every(entry => entry.routeClass === 'primary-forward' && !entry.labeled)) return false
681
- const side = entries[0]!.side
682
- if (!entries.every(entry => entry.side === side)) return false
683
-
684
- const pairCounts = new Map<string, number>()
685
- for (const entry of entries) {
686
- const pairKey = `${entry.sourceId}\0${entry.targetId}`
687
- pairCounts.set(pairKey, (pairCounts.get(pairKey) ?? 0) + 1)
688
- }
689
- const hasDuplicatePair = [...pairCounts.values()].some(count => count >= 2)
690
- if (!hasDuplicatePair) return false
691
-
692
- // Target-side fan-in with a duplicate pair is exactly the face owned by
693
- // applyParallelDuplicateLanes. Source-side ordering is safe only when every
694
- // source-side port belongs to a duplicated directed pair; otherwise an
695
- // unrelated fan-out would be ordered before the lane planner owns it.
696
- if (entries.every(entry => entry.endpoint === 'target')) return true
697
- if (entries.every(entry => entry.endpoint === 'source')) {
698
- return entries.every(entry => (pairCounts.get(`${entry.sourceId}\0${entry.targetId}`) ?? 0) >= 2)
699
- }
700
- return false
701
- }
702
-
703
- export function buildRoutePortHints(graph: MermaidGraph, subgraphIds: Set<string>): RoutePortHints {
704
- const classes = classifyRoutes(graph)
705
- // Fixed-side ports are useful pre-layout hints when the route class has a
706
- // stable semantic side. Primary-forward edges use the flow sides; feedback
707
- // uses the flipped sides. Container, cross-hierarchy and self-loop routes are
708
- // still relaxed to the final certifying repair pass because their attachment
709
- // side depends on compound/family geometry rather than node-local flow.
710
- const nonPrimaryIncident = new Set<string>()
711
- const unsafeIncident = new Set<string>()
712
- const directionOverrideNodes = directedSubgraphNodeIds(graph.subgraphs)
713
- const labeledPrimaryPairs = new Set<string>()
714
- for (let edgeIndex = 0; edgeIndex < graph.edges.length; edgeIndex++) {
715
- const edge = graph.edges[edgeIndex]!
716
- const routeClass = classes[edgeIndex] ?? 'primary-forward'
717
- if (routeClass === 'primary-forward' && edge.label) labeledPrimaryPairs.add(`${edge.source}\u0000${edge.target}`)
718
- if (routeClass !== 'primary-forward') {
719
- nonPrimaryIncident.add(edge.source)
720
- nonPrimaryIncident.add(edge.target)
721
- }
722
- if (routeClass !== 'primary-forward' && routeClass !== 'feedback') {
723
- unsafeIncident.add(edge.source)
724
- unsafeIncident.add(edge.target)
725
- }
726
- }
727
- const drafts: RoutePortHint[] = []
728
- const relaxations: RoutePortRelaxation[] = []
729
- const safeId = (id: string) => id.replace(/[^A-Za-z0-9_\-]/g, '_')
730
- const relax = (edgeIndex: number, routeClass: RouteClass, reason: RoutePortRelaxation['reason']): void => {
731
- relaxations.push({ edgeIndex, routeClass, reason })
732
- }
733
- for (let edgeIndex = 0; edgeIndex < graph.edges.length; edgeIndex++) {
734
- const edge = graph.edges[edgeIndex]!
735
- const routeClass = classes[edgeIndex] ?? 'primary-forward'
736
- if (edge.source === edge.target) { relax(edgeIndex, routeClass, 'self-loop'); continue }
737
- if (subgraphIds.has(edge.source) || subgraphIds.has(edge.target)) { relax(edgeIndex, routeClass, 'subgraph-endpoint'); continue }
738
- if (!graph.nodes.has(edge.source) || !graph.nodes.has(edge.target)) { relax(edgeIndex, routeClass, 'missing-node'); continue }
739
- if (routeClass !== 'primary-forward' && routeClass !== 'feedback') { relax(edgeIndex, routeClass, 'unsupported-route-class'); continue }
740
- if (routeClass === 'primary-forward' && (nonPrimaryIncident.has(edge.source) || nonPrimaryIncident.has(edge.target))) {
741
- relax(edgeIndex, routeClass, 'unsafe-non-primary-incident')
742
- continue
743
- }
744
- if (routeClass === 'feedback' && edge.label) { relax(edgeIndex, routeClass, 'unsupported-route-class'); continue }
745
- if (routeClass === 'feedback' && labeledPrimaryPairs.has(`${edge.target}\u0000${edge.source}`)) {
746
- relax(edgeIndex, routeClass, 'labeled-reciprocal-primary')
747
- continue
748
- }
749
- if (routeClass === 'feedback' &&
750
- (!supportsPreLayoutFeedbackPorts(graph.nodes.get(edge.source)) || !supportsPreLayoutFeedbackPorts(graph.nodes.get(edge.target)))) {
751
- relax(edgeIndex, routeClass, 'unsupported-shape')
752
- continue
753
- }
754
- if (unsafeIncident.has(edge.source) || unsafeIncident.has(edge.target)) { relax(edgeIndex, routeClass, 'unsafe-non-primary-incident'); continue }
755
- if (directionOverrideNodes.has(edge.source) || directionOverrideNodes.has(edge.target)) { relax(edgeIndex, routeClass, 'direction-override-node'); continue }
756
- const sides = routeHintSides(graph.direction, routeClass)
757
- drafts.push({
758
- nodeId: edge.source,
759
- portId: `rp_${safeId(edge.source)}_${edgeIndex}_s`,
760
- side: sides.source,
761
- edgeIndex,
762
- sourceId: edge.source,
763
- targetId: edge.target,
764
- endpoint: 'source',
765
- slotIndex: 0,
766
- routeClass,
767
- labeled: Boolean(edge.label),
768
- constraint: 'FIXED_SIDE',
769
- })
770
- drafts.push({
771
- nodeId: edge.target,
772
- portId: `rp_${safeId(edge.target)}_${edgeIndex}_t`,
773
- side: sides.target,
774
- edgeIndex,
775
- sourceId: edge.source,
776
- targetId: edge.target,
777
- endpoint: 'target',
778
- slotIndex: 0,
779
- routeClass,
780
- labeled: Boolean(edge.label),
781
- constraint: 'FIXED_SIDE',
782
- })
783
- }
784
-
785
- const byNode = new Map<string, RoutePortHint[]>()
786
- for (const draft of drafts) {
787
- const entries = byNode.get(draft.nodeId) ?? []
788
- entries.push(draft)
789
- byNode.set(draft.nodeId, entries)
790
- }
791
- for (const entries of byNode.values()) {
792
- entries.sort((a, b) => sideSort(a.side) - sideSort(b.side) || a.edgeIndex - b.edgeIndex || endpointSort(a.endpoint) - endpointSort(b.endpoint))
793
- const perSide = new Map<string, number>()
794
- for (const entry of entries) {
795
- const next = perSide.get(entry.side) ?? 0
796
- entry.slotIndex = next
797
- perSide.set(entry.side, next + 1)
798
- }
799
- const ordered = supportsPreLayoutFixedOrderPorts(graph.nodes.get(entries[0]!.nodeId)) &&
800
- certifiesPrimaryDuplicateFaceOrder(entries)
801
- for (const entry of entries) {
802
- if (ordered) {
803
- entry.constraint = 'FIXED_ORDER'
804
- entry.orderReason = 'primary-duplicate-face'
805
- } else {
806
- entry.constraint = 'FIXED_SIDE'
807
- entry.orderReason = undefined
808
- }
809
- }
810
- }
811
-
812
- const byEndpoint = new Map<string, RoutePortHint>()
813
- for (const draft of drafts) byEndpoint.set(endpointKey(draft.edgeIndex, draft.endpoint), draft)
814
- return { byEndpoint, byNode, relaxations }
815
- }
816
-
817
- function sideSort(side: 'N' | 'E' | 'S' | 'W'): number {
818
- return { N: 0, E: 1, S: 2, W: 3 }[side]
819
- }
820
-
821
- function endpointSort(endpoint: 'source' | 'target'): number {
822
- return endpoint === 'source' ? 0 : 1
823
- }
824
-
825
- function elkPortSide(side: 'N' | 'E' | 'S' | 'W'): 'NORTH' | 'EAST' | 'SOUTH' | 'WEST' {
826
- switch (side) {
827
- case 'N': return 'NORTH'
828
- case 'E': return 'EAST'
829
- case 'S': return 'SOUTH'
830
- case 'W': return 'WEST'
831
- }
832
- }
833
-
834
- function nodeToElkLeaf(id: string, node: MermaidNode, style: ResolvedRenderStyle, hints: RoutePortHints): ElkGraphNode {
835
- const size = estimateNodeSize(id, node.label, node.shape, style)
836
- const elkNode: ElkGraphNode = {
837
- id,
838
- width: size.width,
839
- height: size.height,
840
- labels: [{ text: node.label }],
841
- }
842
- const ports = hints.byNode.get(id)
843
- if (ports && ports.length > 0) {
844
- const constraint = ports.some(p => p.constraint === 'FIXED_ORDER') ? 'FIXED_ORDER' : 'FIXED_SIDE'
845
- elkNode.layoutOptions = { ...(elkNode.layoutOptions ?? {}), 'elk.portConstraints': constraint }
846
- ;(elkNode as unknown as Record<string, unknown>).ports = ports.map(p => ({
847
- id: p.portId,
848
- width: 0,
849
- height: 0,
850
- layoutOptions: {
851
- 'elk.port.side': elkPortSide(p.side),
852
- 'elk.port.index': String(p.slotIndex),
853
- },
854
- }))
855
- }
856
- return elkNode
857
- }
858
-
859
- function crossHierarchyElkEdge(
860
- info: CrossHierarchyEdgeInfo,
861
- style: ResolvedRenderStyle,
862
- balancingLabels?: Map<number, BalancingLabel>,
863
- ): ElkExtendedEdge {
864
- const { index, edge, sourceSubgraph, targetSubgraph, hostSubgraph } = info
865
- const elkEdge: ElkExtendedEdge = {
866
- id: `e${index}`,
867
- sources: sourceSubgraph && sourceSubgraph !== hostSubgraph ? [`${sourceSubgraph}_out_${index}`] : [edge.source],
868
- targets: targetSubgraph && targetSubgraph !== hostSubgraph ? [`${targetSubgraph}_in_${index}`] : [edge.target],
869
- }
870
- if (edge.label && !layoutEnvFlag('APL_DECOUPLE_LABELS')) {
871
- const label = applyTextTransform(edge.label, style.edgeTextTransform)
872
- const metrics = measureMultilineText(label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
873
- elkEdge.labels = [{
874
- text: label,
875
- width: metrics.width + 8,
876
- height: metrics.height + 6,
877
- layoutOptions: {
878
- 'elk.edgeLabels.inline': 'true',
879
- 'elk.edgeLabels.placement': 'CENTER',
880
- },
881
- }]
882
- } else if (!edge.label && balancingLabels?.has(index)) {
883
- elkEdge.labels = [balancingElkLabel(balancingLabels.get(index)!)]
884
- }
885
- return elkEdge
886
- }
887
-
888
- /**
889
- * Convert a MermaidSubgraph to an ELK compound node.
890
- * Includes internal edges (edges where both endpoints are in this subgraph)
891
- * so that the subgraph's direction override is respected by ELK.
892
- *
893
- * When using SEPARATE hierarchy handling (for direction override support),
894
- * also adds hierarchical ports for cross-hierarchy edges.
895
- */
896
- function subgraphToElk(
897
- sg: MermaidSubgraph,
898
- graph: MermaidGraph,
899
- opts: Required<Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>>,
900
- style: ResolvedRenderStyle,
901
- edgesBySubgraph: Map<string | null, Array<{ index: number; edge: MermaidEdge }>>,
902
- subgraphPorts: Map<string, Array<{
903
- portId: string
904
- edgeIndex: number
905
- direction: 'incoming' | 'outgoing'
906
- internalNodeId: string
907
- }>>,
908
- crossEdgesByHost: Map<string | null, CrossHierarchyEdgeInfo[]>,
909
- routePortHints: RoutePortHints,
910
- useSourceAwareOrder: boolean,
911
- balancingLabels: Map<number, BalancingLabel>,
912
- ): ElkGraphNode {
913
- const groupHeaderHeight = style.groupHeaderFontSize + 16
914
- const layoutOptions: LayoutOptions = {
915
- 'elk.algorithm': 'layered',
916
- 'elk.padding': `[top=${groupHeaderHeight + style.groupPaddingY},left=${style.groupPaddingX},bottom=${style.groupPaddingY},right=${style.groupPaddingX}]`,
917
- 'elk.edgeRouting': 'ORTHOGONAL',
918
- 'elk.contentAlignment': 'H_CENTER V_CENTER',
919
- 'elk.spacing.edgeEdge': '12',
920
- 'elk.layered.spacing.edgeEdgeBetweenLayers': '12',
921
- 'elk.layered.spacing.edgeNodeBetweenLayers': '12',
922
- 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
923
- 'elk.layered.spacing.nodeNodeBetweenLayers': String(opts.layerSpacing),
924
- 'elk.spacing.nodeNode': String(opts.nodeSpacing),
925
- }
926
-
927
- // Apply direction override if specified
928
- if (sg.direction) {
929
- layoutOptions['elk.direction'] = directionToElk(sg.direction)
930
- }
931
-
932
- const elkNode: ElkGraphNode = {
933
- id: sg.id,
934
- layoutOptions,
935
- labels: sg.label ? [{ text: sg.label }] : undefined,
936
- children: [],
937
- edges: [],
938
- }
939
-
940
- // Add hierarchical ports for cross-hierarchy edges (when using SEPARATE)
941
- const ports = subgraphPorts.get(sg.id) ?? []
942
- if (ports.length > 0) {
943
- // ELK supports ports but types don't include it
944
- (elkNode as unknown as Record<string, unknown>).ports = ports.map(p => ({
945
- id: p.portId,
946
- // Port side is determined by ELK based on edge direction
947
- }))
948
- }
949
-
950
- // Add direct child nodes using the same source-aware ordering within the group
951
- // for flowchart-like graphs; architecture keeps parser order for stable
952
- // group-boundary routing.
953
- const childNodeOrder = useSourceAwareOrder ? sourceAwareNodeOrder(sg.nodeIds, graph.edges) : sg.nodeIds
954
- for (const nodeId of childNodeOrder) {
955
- const node = graph.nodes.get(nodeId)
956
- if (node) {
957
- elkNode.children!.push(nodeToElkLeaf(nodeId, node, style, routePortHints))
958
- }
959
- }
960
-
961
- // Add nested subgraphs recursively
962
- for (const child of sg.children) {
963
- elkNode.children!.push(subgraphToElk(child, graph, opts, style, edgesBySubgraph, subgraphPorts, crossEdgesByHost, routePortHints, useSourceAwareOrder, balancingLabels))
964
- }
965
-
966
- // Add internal edges (edges where both endpoints are in this subgraph)
967
- const internalEdges = edgesBySubgraph.get(sg.id) ?? []
968
- for (const { index, edge } of internalEdges) {
969
- const elkEdge: ElkExtendedEdge = {
970
- id: `e${index}`,
971
- sources: [routePortHints.byEndpoint.get(endpointKey(index, 'source'))?.portId ?? edge.source],
972
- targets: [routePortHints.byEndpoint.get(endpointKey(index, 'target'))?.portId ?? edge.target],
973
- }
974
- if (edge.label && !layoutEnvFlag('APL_DECOUPLE_LABELS')) {
975
- const label = applyTextTransform(edge.label, style.edgeTextTransform)
976
- const metrics = measureMultilineText(label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
977
- elkEdge.labels = [{
978
- text: label,
979
- width: metrics.width + 8,
980
- height: metrics.height + 6,
981
- layoutOptions: {
982
- 'elk.edgeLabels.inline': 'true',
983
- 'elk.edgeLabels.placement': 'CENTER',
984
- },
985
- }]
986
- } else if (!edge.label && balancingLabels.has(index)) {
987
- elkEdge.labels = [balancingElkLabel(balancingLabels.get(index)!)]
988
- }
989
- elkNode.edges!.push(elkEdge)
990
- }
991
-
992
- // Add cross-hierarchy external segments hosted by this compound. Ports are
993
- // only used for endpoints that live in a nested child compound; direct
994
- // children of this host are referenced by node id.
995
- for (const info of crossEdgesByHost.get(sg.id) ?? []) {
996
- elkNode.edges!.push(crossHierarchyElkEdge(info, style))
997
- }
998
-
999
- // Add internal edge segments for hierarchical ports (port → node or node → port)
1000
- // These connect the boundary ports to actual internal nodes
1001
- for (const port of ports) {
1002
- const internalEdgeId = `e${port.edgeIndex}_internal`
1003
- const elkEdge: ElkExtendedEdge = port.direction === 'incoming'
1004
- ? { id: internalEdgeId, sources: [port.portId], targets: [port.internalNodeId] }
1005
- : { id: internalEdgeId, sources: [port.internalNodeId], targets: [port.portId] }
1006
- elkNode.edges!.push(elkEdge)
1007
- }
1008
-
1009
- return elkNode
1010
- }
1011
-
1012
- /** Recursively collect all node IDs that belong to any subgraph */
1013
- function collectSubgraphNodeIds(sg: MermaidSubgraph, nodeIds: Set<string>, subgraphIds: Set<string>): void {
1014
- for (const id of sg.nodeIds) {
1015
- nodeIds.add(id)
1016
- }
1017
- for (const child of sg.children) {
1018
- subgraphIds.add(child.id)
1019
- collectSubgraphNodeIds(child, nodeIds, subgraphIds)
1020
- }
1021
- }
1022
-
1023
- /**
1024
- * Build a mapping from node ID to its containing subgraph ID.
1025
- * For nested subgraphs, maps to the innermost containing subgraph.
1026
- * Nodes not in any subgraph are not included in the map.
1027
- */
1028
- function buildNodeToSubgraphMap(subgraphs: MermaidSubgraph[]): Map<string, string> {
1029
- const map = new Map<string, string>()
1030
-
1031
- function traverse(sg: MermaidSubgraph): void {
1032
- // Map all direct child nodes to this subgraph
1033
- for (const nodeId of sg.nodeIds) {
1034
- map.set(nodeId, sg.id)
1035
- }
1036
- // Recursively process nested subgraphs (they override parent mapping)
1037
- for (const child of sg.children) {
1038
- traverse(child)
1039
- }
1040
- }
1041
-
1042
- for (const sg of subgraphs) {
1043
- traverse(sg)
1044
- }
1045
-
1046
- return map
1047
- }
1048
-
1049
- function buildNodeToRootSubgraphMap(subgraphs: MermaidSubgraph[]): Map<string, string> {
1050
- const map = new Map<string, string>()
1051
- function traverse(sg: MermaidSubgraph, rootId: string): void {
1052
- for (const nodeId of sg.nodeIds) map.set(nodeId, rootId)
1053
- for (const child of sg.children) traverse(child, rootId)
1054
- }
1055
- for (const sg of subgraphs) traverse(sg, sg.id)
1056
- return map
1057
- }
1058
-
1059
- function buildNodeSubgraphAncestorsMap(subgraphs: MermaidSubgraph[]): Map<string, string[]> {
1060
- const map = new Map<string, string[]>()
1061
- function traverse(sg: MermaidSubgraph, ancestors: string[]): void {
1062
- const next = [...ancestors, sg.id]
1063
- for (const nodeId of sg.nodeIds) map.set(nodeId, next)
1064
- for (const child of sg.children) traverse(child, next)
1065
- }
1066
- for (const sg of subgraphs) traverse(sg, [])
1067
- return map
1068
- }
1069
-
1070
- function buildSubgraphParentMap(subgraphs: MermaidSubgraph[]): Map<string, string | undefined> {
1071
- const map = new Map<string, string | undefined>()
1072
- function traverse(sg: MermaidSubgraph, parent: string | undefined): void {
1073
- map.set(sg.id, parent)
1074
- for (const child of sg.children) traverse(child, sg.id)
1075
- }
1076
- for (const sg of subgraphs) traverse(sg, undefined)
1077
- return map
1078
- }
1079
-
1080
- function buildSubgraphAncestorsMap(subgraphs: MermaidSubgraph[]): Map<string, string[]> {
1081
- const map = new Map<string, string[]>()
1082
- function traverse(sg: MermaidSubgraph, ancestors: string[]): void {
1083
- const next = [...ancestors, sg.id]
1084
- map.set(sg.id, next)
1085
- for (const child of sg.children) traverse(child, next)
1086
- }
1087
- for (const sg of subgraphs) traverse(sg, [])
1088
- return map
1089
- }
1090
-
1091
- function deepestCommonAncestor(a: string[], b: string[]): string | undefined {
1092
- let common: string | undefined
1093
- for (let i = 0; i < Math.min(a.length, b.length); i++) {
1094
- if (a[i] !== b[i]) break
1095
- common = a[i]
1096
- }
1097
- return common
1098
- }
1099
-
1100
- function rootChildOrder(
1101
- graph: MermaidGraph,
1102
- subgraphNodeIds: Set<string>,
1103
- subgraphIds: Set<string>,
1104
- nodeToRootSubgraph: Map<string, string>,
1105
- seedSubgraphsFirst: boolean,
1106
- ): string[] {
1107
- const rootIds: string[] = []
1108
- const seen = new Set<string>()
1109
- const add = (id: string) => {
1110
- if (seen.has(id)) return
1111
- seen.add(id)
1112
- rootIds.push(id)
1113
- }
1114
-
1115
- const addNodeDerivedRootIds = () => {
1116
- for (const id of graph.nodes.keys()) {
1117
- const root = nodeToRootSubgraph.get(id)
1118
- if (root) add(root)
1119
- else if (!subgraphNodeIds.has(id) && !subgraphIds.has(id)) add(id)
1120
- }
1121
- }
1122
- const addDeclaredSubgraphIds = () => {
1123
- for (const sg of graph.subgraphs) add(sg.id)
1124
- }
1125
-
1126
- // Flowchart-like graphs should keep root nodes and top-level subgraphs in
1127
- // source-derived order. Projected families such as architecture can request
1128
- // top-level group declaration order because their parser has group entries
1129
- // that are not represented in graph.nodes until a service/junction appears.
1130
- if (seedSubgraphsFirst) {
1131
- addDeclaredSubgraphIds()
1132
- addNodeDerivedRootIds()
1133
- } else {
1134
- addNodeDerivedRootIds()
1135
- addDeclaredSubgraphIds()
1136
- }
1137
-
1138
- const rootEdges = graph.edges.map(edge => ({
1139
- source: nodeToRootSubgraph.get(edge.source) ?? edge.source,
1140
- target: nodeToRootSubgraph.get(edge.target) ?? edge.target,
1141
- }))
1142
- return sourceAwareNodeOrder(rootIds, rootEdges)
1143
- }
1144
-
1145
- // ============================================================================
1146
- // Result conversion: ELK output → PositionedGraph
1147
- // ============================================================================
1148
-
1149
- /**
1150
- * Convert ELK layout result to our PositionedGraph format.
1151
- */
1152
- /** The mutable bag the post-ELK geometry passes thread through (see ./layout/pass.ts). */
1153
- export interface LayoutPassContext extends PassContextBase {
1154
- readonly elkResult: ElkNode
1155
- readonly graph: MermaidGraph
1156
- nodes: PositionedNode[]
1157
- edges: PositionedEdge[]
1158
- groups: PositionedGroup[]
1159
- readonly margins: MarginInfo | undefined
1160
- bundled: Set<PositionedEdge>
1161
- readonly mergeEdges: boolean
1162
- readonly style: ResolvedRenderStyle
1163
- readonly layoutPadding: number
1164
- frozen: boolean
1165
- }
1166
-
1167
- // The post-ELK geometry pipeline, reified (docs/design/system/layout-pass-pipeline.md).
1168
- // The array IS the execution path; each `run` delegates to the existing pass function
1169
- // with byte-identical arguments. The three symmetric passes carry no `after` among each
1170
- // other — their order is empirically free (spec §8 R1, verified by a permutation experiment).
1171
- export const LAYOUT_PIPELINE: ReadonlyArray<LayoutPass<LayoutPassContext>> = [
1172
- {
1173
- id: 'extractEdgesRecursively', doc: 'flatten ELK edges to absolute coords (+orthogonalize cross-hierarchy)',
1174
- after: [], mutates: ['extract'], determinism: 'pure-order',
1175
- run: c => { extractEdgesRecursively(c.elkResult, c.graph, c.edges, 0, 0, c.margins) },
1176
- },
1177
- {
1178
- id: 'alignLayerNodes', doc: 'snap same-layer nodes onto a shared flow-axis line',
1179
- after: ['extractEdgesRecursively'], mutates: ['positions', 'edges'], determinism: 'in-place',
1180
- mayChangeMetrics: { bends: 'improve-only', straight: 'improve-only' },
1181
- run: c => { alignLayerNodes(c.nodes, c.edges, c.graph.direction) },
1182
- },
1183
- {
1184
- id: 'equalizePeerNodeDimensions', doc: 'equalize peer box sizes + pack layers so symmetry is visible downstream',
1185
- after: ['alignLayerNodes'], mutates: ['positions', 'dimensions'], determinism: 'in-place',
1186
- mayChangeMetrics: { symErr: 'improve-only' },
1187
- run: c => { equalizePeerNodeDimensions(c.nodes, c.edges, c.groups, c.graph) },
1188
- },
1189
- {
1190
- id: 'alignForkRejoinPeerCenters', doc: 'center fork/rejoin hubs on their peer barycenter',
1191
- after: ['equalizePeerNodeDimensions'], mutates: ['positions'], determinism: 'in-place',
1192
- mayChangeMetrics: { symErr: 'improve-only', bends: { worsenBy: 2 } },
1193
- run: c => { alignForkRejoinPeerCenters(c.nodes, c.edges, c.groups, c.graph, c.style) },
1194
- },
1195
- {
1196
- id: 'alignPortLanes', doc: 'slide one endpoint node so a floating-straight edge becomes port-exact (Ruegg GD15)',
1197
- after: ['alignForkRejoinPeerCenters'], mutates: ['positions', 'edges'], determinism: 'in-place',
1198
- mayChangeMetrics: { straight: 'improve-only', portRate: 'improve-only', bends: 'improve-only' },
1199
- run: c => { alignPortLanes(c.nodes, c.edges, c.groups, c.graph.direction, c.style) },
1200
- },
1201
- {
1202
- id: 'centerPeerBarycenters', doc: 'center peer fan-in/fan-out trunks over peer barycenters (#57/#61)',
1203
- after: ['alignPortLanes'], mutates: ['positions'], determinism: 'in-place',
1204
- mayChangeMetrics: { symErr: 'improve-only', bends: { worsenBy: 2 } },
1205
- run: c => { centerPeerBarycenters(c.nodes, c.edges, c.groups, c.graph, c.style) },
1206
- },
1207
- {
1208
- id: 'honorLinkRankDistance', doc: 'shove target sub-DAG to honor variable-length link rank distance; push ahead anything the shove lands on and rebuild blocked reconnect routes through free channels (#81)',
1209
- after: ['centerPeerBarycenters'], mutates: ['positions'], determinism: 'in-place',
1210
- run: c => { honorLinkRankDistance(c.nodes, c.edges, c.groups, c.graph) },
1211
- },
1212
- {
1213
- // Runs LAST among the node-movers (after centering/rank-shoving settle the
1214
- // target's entry lane) but before the freeze, so it aligns the labelled
1215
- // source onto the target's FINAL lane.
1216
- id: 'alignLabeledSourcePort', doc: 'slide a single-outgoing labelled source onto the lane the straightener will use so the exit stays mid-port (alignPortLanes excludes labelled edges)',
1217
- after: ['honorLinkRankDistance'], mutates: ['positions', 'edges'], determinism: 'in-place',
1218
- mayChangeMetrics: { portRate: 'improve-only' },
1219
- run: c => { alignLabeledSourcePort(c.nodes, c.edges, c.groups, c.graph, c.graph.direction, c.style) },
1220
- },
1221
- {
1222
- id: 'bundleEdgePaths', doc: 'bundle fan-out/fan-in edges into shared trunks (when mergeEdges)',
1223
- after: ['alignLabeledSourcePort'], mutates: ['edges'], determinism: 'pure-order',
1224
- enabled: c => c.mergeEdges,
1225
- run: c => { c.bundled = bundleEdgePaths(c.edges, c.nodes, c.groups, c.graph.direction) },
1226
- },
1227
- {
1228
- // Re-route the co-ranked mixed-label fan-in spokes as symmetric converging
1229
- // doglegs and mark them bundle-owned, so applyRouteContracts certifies them
1230
- // 'bundle' — the justified-bend marker findRouteHitches and the layout-rubric
1231
- // both key off. Independent of mergeEdges: the fan-in converges via centering
1232
- // whether or not the plain bundler ran, so the marker must too.
1233
- id: 'markCorankFanInBundles', doc: 're-route + mark co-ranked mixed-label fan-in spokes bundle-owned (justified symmetric-convergence bend)',
1234
- after: ['bundleEdgePaths'], mutates: ['edges'], determinism: 'in-place',
1235
- run: c => { markCorankFanInBundles(c.nodes, c.edges, c.groups, c.graph, c.bundled, c.style) },
1236
- },
1237
- {
1238
- id: 'clipEdgeToShape', doc: 'clip edge endpoints to real (non-rect) shape outlines',
1239
- after: ['bundleEdgePaths'], mutates: ['edges'], determinism: 'in-place',
1240
- mayChangeMetrics: { portRate: 'improve-only' },
1241
- run: c => {
1242
- const nodeMap = new Map(c.nodes.map(n => [n.id, n]))
1243
- for (const edge of c.edges) {
1244
- const sourceNode = nodeMap.get(edge.source)
1245
- const targetNode = nodeMap.get(edge.target)
1246
- if (sourceNode) edge.points = clipEdgeToShape(edge.points, sourceNode, true)
1247
- if (targetNode) edge.points = clipEdgeToShape(edge.points, targetNode, false)
1248
- }
1249
- },
1250
- },
1251
- {
1252
- id: 'applySymmetricFanoutEmissions', doc: 're-route small equivalent fan-outs symmetrically; mark bundle-owned',
1253
- after: ['clipEdgeToShape'], mutates: ['edges'], determinism: 'in-place',
1254
- mayChangeMetrics: { symErr: 'improve-only', bends: { worsenBy: 2 } },
1255
- run: c => { applySymmetricFanoutEmissions(c.nodes, c.edges, c.groups, c.graph, c.bundled, c.style) },
1256
- },
1257
- {
1258
- id: 'applySymmetricParallelEdgeLanes', doc: 'separate parallel edges into symmetric non-crossing lanes',
1259
- after: ['clipEdgeToShape'], mutates: ['edges'], determinism: 'in-place',
1260
- mayChangeMetrics: { symErr: 'improve-only', crossings: 'improve-only' },
1261
- run: c => { applySymmetricParallelEdgeLanes(c.nodes, c.edges, c.groups, c.graph, c.bundled, c.style) },
1262
- },
1263
- {
1264
- id: 'applyParallelDuplicateLanes', doc: 'split exact duplicate edges into separated lanes',
1265
- after: ['clipEdgeToShape'], mutates: ['edges'], determinism: 'in-place',
1266
- mayChangeMetrics: { crossings: 'improve-only' },
1267
- run: c => { applyParallelDuplicateLanes(c.nodes, c.edges, c.groups, c.graph, c.bundled) },
1268
- },
1269
- {
1270
- id: 'collapseTinyBundledHitches', doc: 'remove sub-perceptual hitches introduced by bundling',
1271
- after: ['applySymmetricFanoutEmissions', 'applySymmetricParallelEdgeLanes', 'applyParallelDuplicateLanes'],
1272
- mutates: ['edges'], determinism: 'in-place', mayChangeMetrics: { bends: 'improve-only' },
1273
- run: c => { collapseTinyBundledHitches(c.nodes, c.edges, c.bundled) },
1274
- },
1275
- {
1276
- id: 'reassignBundledSiblingLabels', doc: 're-home labels onto the correct bundled sibling segment',
1277
- after: ['collapseTinyBundledHitches'], mutates: ['edges'], determinism: 'in-place',
1278
- run: c => { reassignBundledSiblingLabels(c.nodes, c.edges, c.bundled, c.graph.direction, c.style) },
1279
- },
1280
- {
1281
- id: 'applyRouteContracts', doc: 'classify -> simplify -> straighten (fixed-point) -> certify; FREEZES node geometry',
1282
- after: ['reassignBundledSiblingLabels'], mutates: ['edges'], determinism: 'fixed-point', freezesNodes: true,
1283
- mayChangeMetrics: { straight: 'improve-only', bends: 'improve-only', portRate: 'improve-only' },
1284
- run: c => { applyRouteContracts({ nodes: c.nodes, edges: c.edges, groups: c.groups }, c.graph, c.bundled, c.style) },
1285
- },
1286
- {
1287
- id: 'reanchorOffOutlineEndpoints', doc: 're-route an edge whose endpoint dangles off a moved node onto that node\'s flow port (edge-only, freeze-safe)',
1288
- after: ['applyRouteContracts'], mutates: ['edges'], determinism: 'in-place',
1289
- run: c => { reanchorOffOutlineEndpoints(c.nodes, c.edges, c.graph) },
1290
- },
1291
- {
1292
- id: 'rerouteEdgesThroughNodes', doc: 're-route an edge left running through a node by a node-mover (honorLinkRankDistance/alignPortLanes) around the obstacle (edge-only, freeze-safe)',
1293
- after: ['reanchorOffOutlineEndpoints'], mutates: ['edges'], determinism: 'in-place',
1294
- run: c => { rerouteEdgesThroughNodes(c.nodes, c.edges, c.graph) },
1295
- },
1296
- {
1297
- id: 'repairLabelsOnSharedTrunks', doc: 're-slot a labeled edge whose pill sits on a trunk shared with another edge (label-only, freeze-safe)',
1298
- after: ['rerouteEdgesThroughNodes'], mutates: ['edges'], determinism: 'in-place',
1299
- run: c => { repairLabelsOnSharedTrunks({ nodes: c.nodes, edges: c.edges, groups: c.groups }, c.graph, c.style) },
1300
- },
1301
- {
1302
- id: 'repairLabelsOffOwnRoute', doc: 're-slot a labeled edge whose pill sits off its OWN route onto it — ELK offset placement on an already-straight edge (label-only, freeze-safe)',
1303
- after: ['repairLabelsOnSharedTrunks'], mutates: ['edges'], determinism: 'in-place',
1304
- run: c => { repairLabelsOffOwnRoute({ nodes: c.nodes, edges: c.edges, groups: c.groups }, c.graph, c.style) },
1305
- },
1306
- {
1307
- id: 'separateEdgeLabelPills', doc: 'slide colliding edge-label pills along their own routes into clear slots — parallel/reciprocal lane labels stack at midpoints (label-only, freeze-safe)',
1308
- after: ['repairLabelsOffOwnRoute'], mutates: ['edges'], determinism: 'in-place',
1309
- run: c => { separateEdgeLabelPills({ nodes: c.nodes, edges: c.edges, groups: c.groups }, c.graph, c.style) },
1310
- },
1311
- {
1312
- id: 'translateGeometryToNonNegativeOrigin', doc: 'shift whole graph to a non-negative origin (allowed after freeze)',
1313
- after: ['repairLabelsOffOwnRoute'], mutates: ['translate'], determinism: 'in-place',
1314
- run: c => { translateGeometryToNonNegativeOrigin(c.nodes, c.edges, c.groups, c.layoutPadding) },
1315
- },
1316
- ]
1317
-
1318
- function elkToPositioned(
1319
- elkResult: ElkNode,
1320
- graph: MermaidGraph,
1321
- mergeEdges: boolean = false,
1322
- layoutPadding: number = DEFAULTS.padding,
1323
- style: ResolvedRenderStyle = resolveRenderStyle({}),
1324
- ): PositionedGraph {
1325
- const nodes: PositionedNode[] = []
1326
- const edges: PositionedEdge[] = []
1327
- const groups: PositionedGroup[] = []
1328
-
1329
- // Build set of subgraph IDs for distinguishing compound nodes from leaf nodes
1330
- const subgraphIds = new Set<string>()
1331
- for (const sg of graph.subgraphs) {
1332
- collectAllSubgraphIds(sg, subgraphIds)
1333
- }
1334
-
1335
- // Extract nodes and groups recursively
1336
- extractNodesAndGroups(elkResult, graph, subgraphIds, nodes, groups, 0, 0)
1337
-
1338
- // Compute margin positions for cross-hierarchy edge routing.
1339
- // Margins sit outside all group bounding boxes so edges don't cross through subgraphs.
1340
- const allBounds = flattenGroupBounds(groups)
1341
- const margins: MarginInfo | undefined = allBounds.length > 0
1342
- ? {
1343
- leftX: Math.min(...allBounds.map(b => b.x)) - 20,
1344
- rightX: Math.max(...allBounds.map(b => b.right)) + 20,
1345
- }
1346
- : undefined
1347
-
1348
- // Post-ELK geometry pipeline (docs/design/system/layout-pass-pipeline.md): the
1349
- // LAYOUT_PIPELINE manifest is the execution path. extractNodesAndGroups + margins
1350
- // (above) are the producer setup; the bounds computation (below) is the epilogue.
1351
- const ctx: LayoutPassContext = {
1352
- elkResult,
1353
- graph,
1354
- nodes,
1355
- edges,
1356
- groups,
1357
- margins,
1358
- bundled: new Set<PositionedEdge>(),
1359
- mergeEdges,
1360
- style,
1361
- layoutPadding,
1362
- frozen: false,
1363
- }
1364
- runPipeline(ctx, LAYOUT_PIPELINE)
1365
-
1366
- // Calculate final bounds including all edge points
1367
- // ELK should include edges in its dimensions, but we verify and expand if needed
1368
- let width = elkResult.width ?? 800
1369
- let height = elkResult.height ?? 600
1370
- const arrowMargin = ARROW_HEAD.width
1371
- const padding = layoutPadding
1372
-
1373
- for (const node of nodes) {
1374
- width = Math.max(width, node.x + node.width + padding)
1375
- height = Math.max(height, node.y + node.height + padding)
1376
- }
1377
- for (const bound of flattenGroupBounds(groups)) {
1378
- width = Math.max(width, bound.right + padding)
1379
- height = Math.max(height, bound.bottom + padding)
1380
- }
1381
- for (const edge of edges) {
1382
- for (const p of edge.points) {
1383
- width = Math.max(width, p.x + arrowMargin + padding)
1384
- height = Math.max(height, p.y + arrowMargin + padding)
1385
- }
1386
- if (edge.labelPosition) {
1387
- width = Math.max(width, edge.labelPosition.x + 60 + padding)
1388
- height = Math.max(height, edge.labelPosition.y + 20 + padding)
1389
- }
1390
- }
1391
-
1392
- return {
1393
- width,
1394
- height,
1395
- nodes,
1396
- edges,
1397
- groups,
1398
- }
1399
- }
1400
-
1401
-
1402
- /**
1403
- * Recursively extract positioned nodes and groups from ELK result.
1404
- */
1405
- function extractNodesAndGroups(
1406
- elkNode: ElkNode,
1407
- graph: MermaidGraph,
1408
- subgraphIds: Set<string>,
1409
- nodes: PositionedNode[],
1410
- groups: PositionedGroup[],
1411
- offsetX: number,
1412
- offsetY: number
1413
- ): void {
1414
- if (!elkNode.children) return
1415
-
1416
- for (const child of elkNode.children) {
1417
- const x = (child.x ?? 0) + offsetX
1418
- const y = (child.y ?? 0) + offsetY
1419
- const width = child.width ?? 0
1420
- const height = child.height ?? 0
1421
-
1422
- if (subgraphIds.has(child.id)) {
1423
- // This is a subgraph/group
1424
- const childGroups: PositionedGroup[] = []
1425
-
1426
- // Recursively process children
1427
- extractNodesAndGroups(child, graph, subgraphIds, nodes, childGroups, x, y)
1428
-
1429
- const mermaidSg = findSubgraph(graph.subgraphs, child.id)
1430
- groups.push({
1431
- id: child.id,
1432
- label: mermaidSg?.label ?? '',
1433
- x,
1434
- y,
1435
- width,
1436
- height,
1437
- children: childGroups,
1438
- })
1439
- } else {
1440
- // This is a leaf node
1441
- const mNode = graph.nodes.get(child.id)
1442
- if (mNode) {
1443
- const inlineStyle = resolveNodeInlineStyle(child.id, graph)
1444
- // User-assigned Mermaid class name(s) for external CSS targeting (#81).
1445
- const assigned = graph.classAssignments.get(child.id)
1446
- const classNames = assigned ? assigned.split(/\s+/).filter(Boolean) : undefined
1447
-
1448
- nodes.push({
1449
- id: child.id,
1450
- label: mNode.label,
1451
- shape: mNode.shape,
1452
- x,
1453
- y,
1454
- width,
1455
- height,
1456
- inlineStyle,
1457
- classNames,
1458
- })
1459
- }
1460
-
1461
- // Also check for nested children (shouldn't happen for leaf nodes, but be safe)
1462
- if (child.children && child.children.length > 0) {
1463
- extractNodesAndGroups(child, graph, subgraphIds, nodes, groups, x, y)
1464
- }
1465
- }
1466
- }
1467
- }
1468
-
1469
- /** Recursively collect all subgraph IDs */
1470
- function collectAllSubgraphIds(sg: MermaidSubgraph, out: Set<string>): void {
1471
- out.add(sg.id)
1472
- for (const child of sg.children) {
1473
- collectAllSubgraphIds(child, out)
1474
- }
1475
- }
1476
-
1477
- // ============================================================================
1478
- // Public API
1479
- // ============================================================================
1480
-
1481
- /**
1482
- * Lay out a parsed MermaidGraph using ELK.js (synchronous).
1483
- * Returns a fully positioned graph ready for rendering.
1484
- */
1485
- export function layoutGraphSync(
1486
- graph: MermaidGraph,
1487
- options: LayoutEngineOptions = {}
1488
- ): PositionedGraph {
1489
- const opts = { ...DEFAULTS, ...options }
1490
- const style = resolveRenderStyle(options)
1491
- const elkGraph = mermaidToElk(graph, opts, style)
1492
- // ELK's bundled (GWT-compiled) code can throw internal exceptions on rare
1493
- // dense multigraphs — observed with feedbackEdges routing, and pre-existing
1494
- // with post-compaction + forced model order. Crash-freedom is part of this
1495
- // renderer's contract, so degrade through progressively plainer option
1496
- // sets; the route-contract pass repairs whatever the survivor produces.
1497
- const degradations: Array<Record<string, string>> = [
1498
- {},
1499
- { 'elk.layered.feedbackEdges': 'false' },
1500
- { 'elk.layered.feedbackEdges': 'false', 'elk.layered.compaction.postCompaction.strategy': 'NONE' },
1501
- {
1502
- 'elk.layered.feedbackEdges': 'false',
1503
- 'elk.layered.compaction.postCompaction.strategy': 'NONE',
1504
- 'elk.layered.crossingMinimization.forceNodeModelOrder': 'false',
1505
- 'elk.layered.highDegreeNodes.treatment': 'false',
1506
- },
1507
- ]
1508
- let result: ElkNode | undefined
1509
- let lastError: unknown
1510
- for (const overrides of degradations) {
1511
- const attempt = Object.keys(overrides).length === 0 ? elkGraph : mermaidToElk(graph, opts, style)
1512
- if (Object.keys(overrides).length > 0) {
1513
- attempt.layoutOptions = { ...attempt.layoutOptions, ...overrides }
1514
- }
1515
- try {
1516
- result = elkLayoutSync(attempt)
1517
- break
1518
- } catch (err) {
1519
- lastError = err
1520
- }
1521
- }
1522
- if (!result) throw lastError
1523
- const positioned = elkToPositioned(result, graph, DEFAULTS.mergeEdges, opts.padding, style)
1524
-
1525
- // Co-rank certify-or-fallback (the robustness-doc doctrine). The co-rank
1526
- // balancing labels re-rank a mixed-label fan-in's sources; on rare graphs
1527
- // (observed in RL/reverse-flow when a co-ranked source also carries an
1528
- // upstream neighbor) that re-rank, after peer-dimension equalization, can push
1529
- // a source box into an UNRELATED node — a HARD node-overlap the co-rank
1530
- // introduced. We never ship a HARD violation: if the co-rank layout has any
1531
- // hard violation AND the disabled layout is clean, fall back to the disabled
1532
- // (base) layout, which is byte-equal to APL_NO_CORANK_FANIN. Only runs the
1533
- // extra assessment when the graph actually used co-rank (the balancing map is
1534
- // non-empty), so the common path pays nothing. Deterministic: layout is fully
1535
- // synchronous, so the env toggle is saved/restored within this call with no
1536
- // interleaving, and the fallback is a pure re-derivation of the same input.
1537
- if (!layoutEnvFlag('APL_NO_CORANK_FANIN') &&
1538
- corankFanInBalancingLabels(graph, style).size > 0 &&
1539
- hardViolations(assessLayout(graph, positioned)).length > 0) {
1540
- const saved = (globalThis as typeof globalThis & { process?: { env?: Record<string, string | undefined> } }).process?.env
1541
- const prev = saved?.APL_NO_CORANK_FANIN
1542
- if (saved) saved.APL_NO_CORANK_FANIN = '1'
1543
- try {
1544
- const fallback = layoutGraphSync(graph, options)
1545
- if (hardViolations(assessLayout(graph, fallback)).length === 0) return fallback
1546
- // Disabled layout is itself not clean (not a co-rank regression): keep the
1547
- // original rather than mask an unrelated pre-existing hard violation.
1548
- } finally {
1549
- if (saved) { if (prev === undefined) delete saved.APL_NO_CORANK_FANIN; else saved.APL_NO_CORANK_FANIN = prev }
1550
- }
1551
- }
1552
- return positioned
1553
- }
1554
-
1555
- /**
1556
- * Convert MermaidGraph to ELK format (for benchmarking conversion overhead).
1557
- */
1558
- export function convertToElkFormat(
1559
- graph: MermaidGraph,
1560
- options: LayoutEngineOptions = {}
1561
- ): ElkNode {
1562
- const opts = { ...DEFAULTS, ...options }
1563
- const style = resolveRenderStyle(options)
1564
- return mermaidToElk(graph, opts, style)
1565
- }