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,3916 +0,0 @@
1
- // Post-ELK geometry passes, relocated from layout-engine.ts
2
- // (docs/design/system/layout-pass-pipeline.md §4 step 2).
3
- //
4
- // One-directional dependency: imports from ./geometry, ../route-contracts, ../shape-clipping,
5
- // ../styles, ../text-metrics, ../color-resolver, ../types — NEVER from ../layout-engine.ts.
6
- // layout-engine.ts imports these passes for the LAYOUT_PIPELINE manifest.
7
- import type { ElkNode } from 'elkjs'
8
- import type {
9
- DiamondFacet,
10
- Direction,
11
- MermaidGraph,
12
- MermaidSubgraph,
13
- Point,
14
- PositionedEdge,
15
- PositionedGroup,
16
- PositionedNode,
17
- } from '../../types.ts'
18
- import { ARROW_HEAD, FLOWCHART_DOTTED_DASH } from '../../styles.ts'
19
- import type { ResolvedRenderStyle } from '../../styles.ts'
20
- import { measureMultilineText } from '../../text-metrics.ts'
21
- import { clipEdgeToShape } from '../../shape-clipping.ts'
22
- import { onShapeOutline, segmentThroughShape } from '../../layout-rubric.ts'
23
- import { classifyRoutes, diamondFacetPorts, labelRect, laneContextFor, PORT_EXACT, recertifyReroutedEdge, shapePorts, simplifyPolyline, straightLaneFor } from '../../route-contracts.ts'
24
- import type { LabelMetricsStyle } from '../../route-contracts.ts'
25
- import { resolveEdgeInlineStyle } from '../../color-resolver.ts'
26
- import {
27
- DEFAULTS,
28
- calculatePathMidpoint,
29
- findSubgraph,
30
- flattenGroupBounds,
31
- layoutDebug,
32
- layoutEnvFlag,
33
- layoutFlow,
34
- nodeCrossCenter,
35
- nodeCrossSize,
36
- nodeCrossStart,
37
- nodeMainCenter,
38
- nodeMainSize,
39
- nodeMainStart,
40
- pointAtPathDistance,
41
- polylineLength,
42
- positionedNodeCenter,
43
- rectsOverlap,
44
- } from '../geometry.ts'
45
- import type { MarginInfo } from '../geometry.ts'
46
-
47
- export function translateGeometryToNonNegativeOrigin(
48
- nodes: PositionedNode[],
49
- edges: PositionedEdge[],
50
- groups: PositionedGroup[],
51
- padding: number,
52
- ): void {
53
- let minX = 0
54
- let minY = 0
55
- const visitGroup = (group: PositionedGroup): void => {
56
- minX = Math.min(minX, group.x)
57
- minY = Math.min(minY, group.y)
58
- for (const child of group.children) visitGroup(child)
59
- }
60
- for (const node of nodes) {
61
- minX = Math.min(minX, node.x)
62
- minY = Math.min(minY, node.y)
63
- }
64
- for (const group of groups) visitGroup(group)
65
- for (const edge of edges) {
66
- for (const point of edge.points) {
67
- minX = Math.min(minX, point.x)
68
- minY = Math.min(minY, point.y)
69
- }
70
- if (edge.labelPosition) {
71
- minX = Math.min(minX, edge.labelPosition.x)
72
- minY = Math.min(minY, edge.labelPosition.y)
73
- }
74
- }
75
- if (minX >= 0 && minY >= 0) return
76
- const dx = minX < 0 ? -minX + padding : 0
77
- const dy = minY < 0 ? -minY + padding : 0
78
- const moveGroup = (group: PositionedGroup): void => {
79
- group.x += dx
80
- group.y += dy
81
- for (const child of group.children) moveGroup(child)
82
- }
83
- for (const node of nodes) { node.x += dx; node.y += dy }
84
- for (const group of groups) moveGroup(group)
85
- for (const edge of edges) {
86
- for (const point of edge.points) { point.x += dx; point.y += dy }
87
- if (edge.labelPosition) { edge.labelPosition.x += dx; edge.labelPosition.y += dy }
88
- }
89
- }
90
-
91
- /**
92
- * Edge segment extracted from ELK result.
93
- * Used to combine external and internal segments of hierarchical edges.
94
- */
95
- interface EdgeSegment {
96
- edgeIndex: number
97
- isInternal: boolean // true for port-to-node segments (e.g., "e3_internal")
98
- points: Point[]
99
- labelPosition?: Point
100
- }
101
- function positionedEdgeForwardish(edge: PositionedEdge, nodeMap: Map<string, PositionedNode>, direction: Direction): boolean {
102
- if (edge.source === edge.target) return false
103
- const source = nodeMap.get(edge.source)
104
- const target = nodeMap.get(edge.target)
105
- if (!source || !target) return false
106
- const f = layoutFlow(direction)
107
- const s = positionedNodeCenter(source)
108
- const t = positionedNodeCenter(target)
109
- return ((f.isHorizontal ? t.x - s.x : t.y - s.y) * f.sign) > 8
110
- }
111
- function sameFlowLayer(group: PositionedNode[], direction: Direction, tolerance = 18): boolean {
112
- if (group.length < 2) return false
113
- const values = group.map(n => nodeMainStart(n, direction))
114
- return Math.max(...values) - Math.min(...values) <= tolerance
115
- }
116
- function logicalGraphReaches(graph: MermaidGraph, from: string, to: string): boolean {
117
- const outgoing = new Map<string, string[]>()
118
- for (const edge of graph.edges) {
119
- if (!outgoing.has(edge.source)) outgoing.set(edge.source, [])
120
- outgoing.get(edge.source)!.push(edge.target)
121
- }
122
- const seen = new Set<string>()
123
- const stack = [from]
124
- while (stack.length > 0) {
125
- const id = stack.pop()!
126
- if (id === to) return true
127
- if (seen.has(id)) continue
128
- seen.add(id)
129
- for (const next of outgoing.get(id) ?? []) stack.push(next)
130
- }
131
- return false
132
- }
133
- function packFlowLayerCrossAxis(nodes: PositionedNode[], direction: Direction): void {
134
- const f = layoutFlow(direction)
135
- const layers: PositionedNode[][] = []
136
- const tolerance = 18
137
- const ordered = [...nodes].sort((a, b) => nodeMainStart(a, direction) - nodeMainStart(b, direction) || nodeCrossStart(a, direction) - nodeCrossStart(b, direction))
138
- for (const node of ordered) {
139
- const main = nodeMainStart(node, direction)
140
- let layer = layers.find(existing => Math.abs(nodeMainStart(existing[0]!, direction) - main) <= tolerance)
141
- if (!layer) { layer = []; layers.push(layer) }
142
- layer.push(node)
143
- }
144
-
145
- const gap = 24
146
- for (const layer of layers) {
147
- const byCross = [...layer].sort((a, b) => nodeCrossStart(a, direction) - nodeCrossStart(b, direction))
148
- let cursor = nodeCrossStart(byCross[0]!, direction)
149
- for (const node of byCross) {
150
- const start = nodeCrossStart(node, direction)
151
- if (start < cursor) {
152
- const delta = cursor - start
153
- if (f.isHorizontal) node.y += delta
154
- else node.x += delta
155
- }
156
- cursor = nodeCrossStart(node, direction) + nodeCrossSize(node, direction) + gap
157
- }
158
- }
159
- }
160
- function nodeInsideGroups(node: PositionedNode, groups: PositionedGroup[]): boolean {
161
- const visit = (items: PositionedGroup[]): boolean => items.some(group =>
162
- (node.x >= group.x - 0.5 && node.y >= group.y - 0.5 &&
163
- node.x + node.width <= group.x + group.width + 0.5 &&
164
- node.y + node.height <= group.y + group.height + 0.5) || visit(group.children))
165
- return visit(groups)
166
- }
167
- function flattenPositionedGroups(groups: PositionedGroup[], out: PositionedGroup[] = []): PositionedGroup[] {
168
- for (const group of groups) {
169
- out.push(group)
170
- flattenPositionedGroups(group.children, out)
171
- }
172
- return out
173
- }
174
- function rectInsideGroup(rect: { x: number; y: number; width: number; height: number }, group: PositionedGroup): boolean {
175
- return rect.x >= group.x - 0.5 &&
176
- rect.y >= group.y - 0.5 &&
177
- rect.x + rect.width <= group.x + group.width + 0.5 &&
178
- rect.y + rect.height <= group.y + group.height + 0.5
179
- }
180
- function expandGroupsForMainShift(
181
- groups: PositionedGroup[],
182
- rects: Array<{ x: number; y: number; width: number; height: number }>,
183
- direction: Direction,
184
- delta: number,
185
- ): void {
186
- if (delta <= 0) return
187
- const f = layoutFlow(direction)
188
- const expanded = new Set<PositionedGroup>()
189
- for (const group of flattenPositionedGroups(groups)) {
190
- if (expanded.has(group) || !rects.some(rect => rectInsideGroup(rect, group))) continue
191
- expanded.add(group)
192
- if (f.isHorizontal) {
193
- if (f.sign > 0) group.width += delta
194
- else {
195
- group.x -= delta
196
- group.width += delta
197
- }
198
- } else if (f.sign > 0) group.height += delta
199
- else {
200
- group.y -= delta
201
- group.height += delta
202
- }
203
- }
204
- }
205
- export function equalizePeerNodeDimensions(nodes: PositionedNode[], edges: PositionedEdge[], groups: PositionedGroup[], graph: MermaidGraph): void {
206
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
207
- const incoming = new Map<string, PositionedEdge[]>()
208
- const outgoing = new Map<string, PositionedEdge[]>()
209
- for (const edge of edges) {
210
- if (!positionedEdgeForwardish(edge, nodeMap, graph.direction)) continue
211
- if (!incoming.has(edge.target)) incoming.set(edge.target, [])
212
- incoming.get(edge.target)!.push(edge)
213
- if (!outgoing.has(edge.source)) outgoing.set(edge.source, [])
214
- outgoing.get(edge.source)!.push(edge)
215
- }
216
-
217
- const peerGroups: PositionedNode[][] = []
218
- const addGroup = (group: PositionedNode[]) => {
219
- if (group.length < 2 || group.length > 5) return
220
- if (!group.every(n => n.shape === 'rectangle')) return
221
- if (group.some(n => nodeInsideGroups(n, groups))) return
222
- if (!sameFlowLayer(group, graph.direction)) return
223
- const key = group.map(n => n.id).sort().join('\0')
224
- if (peerGroups.some(g => g.map(n => n.id).sort().join('\0') === key)) return
225
- peerGroups.push(group)
226
- }
227
-
228
- for (const candidate of incoming.values()) {
229
- const targetId = candidate[0]?.target
230
- const sources = candidate.map(e => nodeMap.get(e.source)!).filter(Boolean)
231
- if (!targetId || !sources.every(source => edges.every(e => e.source !== source.id || e.target === targetId))) continue
232
- addGroup(sources)
233
- }
234
-
235
- for (const candidate of outgoing.values()) {
236
- const sourceId = candidate[0]?.source
237
- const targets = candidate.map(e => nodeMap.get(e.target)!).filter(Boolean)
238
- if (!sourceId || !targets.every(target => edges.every(e => e.target !== target.id || e.source === sourceId) && edges.every(e => e.source !== target.id))) continue
239
- let peer = true
240
- for (let i = 0; i < targets.length; i++) for (let j = 0; j < targets.length; j++) {
241
- if (i !== j && logicalGraphReaches(graph, targets[i]!.id, targets[j]!.id)) peer = false
242
- }
243
- if (peer) addGroup(targets)
244
- }
245
-
246
- let changed = false
247
- const f = layoutFlow(graph.direction)
248
- for (const group of peerGroups) {
249
- const maxWidth = Math.max(...group.map(n => n.width))
250
- const maxHeight = Math.max(...group.map(n => n.height))
251
- if (group.every(n => Math.abs(n.width - maxWidth) < 0.5 && Math.abs(n.height - maxHeight) < 0.5)) continue
252
- const ordered = [...group].sort((a, b) => nodeCrossStart(a, graph.direction) - nodeCrossStart(b, graph.direction))
253
- let cursor = Math.min(...ordered.map(n => nodeCrossStart(n, graph.direction)))
254
- const mainStart = Math.min(...ordered.map(n => nodeMainStart(n, graph.direction)))
255
- const proposed: Array<{ node: PositionedNode; x: number; y: number }> = []
256
- for (const node of ordered) {
257
- proposed.push({ node, x: f.isHorizontal ? mainStart : cursor, y: f.isHorizontal ? cursor : mainStart })
258
- cursor += (f.isHorizontal ? maxHeight : maxWidth) + 24
259
- }
260
- // Equalizing grows each peer to the shared max on the MAIN axis. In a reversed
261
- // flow (RL/BT) that growth reaches back into the peer's own upstream node —
262
- // and packFlowLayerCrossAxis (cross-axis only) cannot pull the two apart,
263
- // leaving a nodeOverlaps/offOutlineEndpoints HARD. Rather than drop the
264
- // equalization, slide the colliding upstream node (and its whole ancestor
265
- // cone, so their relative layout is preserved) further along +main into the
266
- // empty region beyond the fan-in, opening room for the wider peer. A peer's
267
- // ORIGINAL main span is used to decide "newly over" — a same-layer sibling it
268
- // already overlapped on the main axis (a pure cross collision) is LEFT to the
269
- // pack, exactly as before, so a clean corpus fan-in triggers nothing here and
270
- // byte-exact equivalence holds.
271
- const ids = new Set(group.map(n => n.id))
272
- const iv = (aLo: number, aHi: number, bLo: number, bHi: number) => aLo < bHi - 0.01 && bLo < aHi - 0.01
273
- const mainIv = (n: { x: number; y: number; width: number; height: number }): [number, number] => {
274
- const lo = f.isHorizontal ? n.x : n.y
275
- return [lo, lo + (f.isHorizontal ? n.width : n.height)]
276
- }
277
- const crossIv = (n: { x: number; y: number; width: number; height: number }): [number, number] => {
278
- const lo = f.isHorizontal ? n.y : n.x
279
- return [lo, lo + (f.isHorizontal ? n.height : n.width)]
280
- }
281
- // The widened peers, as rectangles (proposed position + shared max size).
282
- const widened = proposed.map(({ node, x, y }) => ({ id: node.id, x, y, width: maxWidth, height: maxHeight, orig: mainIv(node) }))
283
- // Non-peer nodes the widening newly extends a peer's main span over while
284
- // cross-aligned (the pack-unresolvable collisions), + the +main shift to clear.
285
- let shift = 0
286
- const colliders = new Set<string>()
287
- for (const w of widened) {
288
- const [pLo, pHi] = mainIv(w), [pcLo, pcHi] = crossIv(w), [woLo, woHi] = w.orig
289
- for (const o of nodes) {
290
- if (ids.has(o.id)) continue
291
- const [qLo, qHi] = mainIv(o), [qcLo, qcHi] = crossIv(o)
292
- if (iv(pLo, pHi, qLo, qHi) && !iv(woLo, woHi, qLo, qHi) && iv(pcLo, pcHi, qcLo, qcHi)) {
293
- colliders.add(o.id)
294
- shift = Math.max(shift, pHi + 24 - qLo)
295
- }
296
- }
297
- }
298
- // Move the colliders plus their upstream cone (ancestors via forward edges).
299
- const moveIds = new Set<string>()
300
- const visit = (id: string) => { if (moveIds.has(id) || ids.has(id)) return; moveIds.add(id); for (const e of incoming.get(id) ?? []) visit(e.source) }
301
- for (const id of colliders) visit(id)
302
- const movers = nodes.filter(n => moveIds.has(n.id))
303
- const origMain = new Map(movers.map(n => [n.id, mainIv(n)] as const))
304
- const snap = [...group, ...movers].map(n => ({ n, x: n.x, y: n.y, w: n.width, h: n.height }))
305
- if (shift > 0) for (const n of movers) n[f.main] += shift
306
- for (const { node, x, y } of proposed) { node.x = x; node.y = y; node.width = maxWidth; node.height = maxHeight }
307
- // Verify: no moved node (widened peer or shifted upstream) NEWLY overlaps a
308
- // node we did not move on the main axis while cross-aligned. If the reposition
309
- // could not clear it (or pushed a mover into something else), revert this group
310
- // entirely and leave it un-equalized — correctness over symmetry.
311
- const moved = new Set<string>([...ids, ...moveIds])
312
- const clash = [...group, ...movers].some(m => {
313
- const [mLo, mHi] = mainIv(m), [mcLo, mcHi] = crossIv(m)
314
- const [woLo, woHi] = origMain.get(m.id) ?? widened.find(w => w.id === m.id)!.orig
315
- return nodes.some(o => {
316
- if (moved.has(o.id)) return false
317
- const [qLo, qHi] = mainIv(o), [qcLo, qcHi] = crossIv(o)
318
- return iv(mLo, mHi, qLo, qHi) && !iv(woLo, woHi, qLo, qHi) && iv(mcLo, mcHi, qcLo, qcHi)
319
- })
320
- })
321
- if (clash) { for (const s of snap) { s.n.x = s.x; s.n.y = s.y; s.n.width = s.w; s.n.height = s.h }; continue }
322
- changed = true
323
- }
324
- if (changed) packFlowLayerCrossAxis(nodes, graph.direction)
325
- }
326
- export function centerPeerBarycenters(
327
- nodes: PositionedNode[],
328
- edges: PositionedEdge[],
329
- groups: PositionedGroup[],
330
- graph: MermaidGraph,
331
- style: LabelMetricsStyle,
332
- ): void {
333
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
334
- // Co-ranking a mixed-label fan-in pre-ELK is the default (see layout-engine
335
- // corankFanInBalancingLabels; disable with APL_NO_CORANK_FANIN). Once the
336
- // fan-in sources share a rank, the labeled-edge exclusion below is what
337
- // otherwise stops the centering — drop it for the fan-in (source-peer) side
338
- // so the hub can square up on the now-co-ranked peers. The sameFlowLayer
339
- // guard still rejects any fan-in the balancing failed to co-rank, and the
340
- // fan-out side stays strict.
341
- const corankFanin = !layoutEnvFlag('APL_NO_CORANK_FANIN')
342
- const candidatePeers = (candidateEdges: PositionedEdge[], peerEnd: 'source' | 'target', allowLabels = false): PositionedNode[] | undefined => {
343
- if (candidateEdges.length < 2 || candidateEdges.length > 6) return undefined
344
- const firstStyle = candidateEdges[0]!.style
345
- if (candidateEdges.some(edge => edge.style !== firstStyle)) return undefined
346
- if (!allowLabels && candidateEdges.some(edge => edge.label)) return undefined
347
- const ids = candidateEdges.map(edge => edge[peerEnd])
348
- if (new Set(ids).size !== ids.length) return undefined
349
- const peers = ids.map(id => nodeMap.get(id)).filter((node): node is PositionedNode => !!node)
350
- if (peers.length !== ids.length) return undefined
351
- // Peer-shape gate depends on the side. FAN-IN peers (peerEnd 'source') may be
352
- // any PORT_EXACT shape: no downstream pass re-spreads fan-in sources, so a
353
- // diamond/round/stadium source is exactly the case that otherwise reverts to
354
- // ELK's off-centre placement — centering the hub over their (exact-port)
355
- // barycenter is pure win. FAN-OUT peers (peerEnd 'target') stay rectangle-only:
356
- // applySymmetricFanoutEmissions OWNS fan-out symmetry and re-spreads rect (or
357
- // diamond-source) fan-outs EVENLY; for non-rect fan-out peers it bails, so
358
- // centering the hub on their raw, unevenly-spaced ELK barycenter would trade a
359
- // clean straight-to-middle-child emit for a worse off-centre one. The hub itself
360
- // may still be a diamond (moveHub guard) — its fan-out is re-spread by that pass.
361
- const peerShapeOk = peerEnd === 'source'
362
- ? (node: PositionedNode) => PORT_EXACT.has(node.shape)
363
- : (node: PositionedNode) => node.shape === 'rectangle'
364
- if (!peers.every(node => peerShapeOk(node) && !nodeInsideGroups(node, groups))) return undefined
365
- if (!sameFlowLayer(peers, graph.direction, 28)) return undefined
366
- for (let i = 0; i < peers.length; i++) for (let j = 0; j < peers.length; j++) {
367
- if (i !== j && logicalGraphReaches(graph, peers[i]!.id, peers[j]!.id)) return undefined
368
- }
369
- return peers
370
- }
371
-
372
- for (let pass = 0; pass < 3; pass++) {
373
- const bySource = new Map<string, PositionedEdge[]>()
374
- const byTarget = new Map<string, PositionedEdge[]>()
375
- for (const edge of edges) {
376
- if (!positionedEdgeForwardish(edge, nodeMap, graph.direction)) continue
377
- if (!bySource.has(edge.source)) bySource.set(edge.source, [])
378
- bySource.get(edge.source)!.push(edge)
379
- if (!byTarget.has(edge.target)) byTarget.set(edge.target, [])
380
- byTarget.get(edge.target)!.push(edge)
381
- }
382
-
383
- let moved = false
384
- const peerBarycenter = (peers: PositionedNode[]): number =>
385
- peers.reduce((sum, peer) => sum + nodeCrossCenter(peer, graph.direction), 0) / peers.length
386
-
387
- // A small terminal fan-out (2–3) is re-spread symmetrically around the hub by
388
- // applySymmetricFanoutEmissions LATER in the pipeline, so that side follows
389
- // wherever the hub lands — its barycenter is "free" and must not pull the hub
390
- // here, or we fight a downstream pass and leave the anchored side off.
391
- const isTerminalFanout = (hubId: string, peers: PositionedNode[]): boolean =>
392
- peers.every(peer => edges.every(edge => edge.source !== peer.id && (edge.target !== peer.id || edge.source === hubId)))
393
- const smallTerminalFanout = (hubId: string, peers: PositionedNode[]): boolean =>
394
- peers.length >= 2 && peers.length <= 3 && isTerminalFanout(hubId, peers)
395
-
396
- // A hub may be a pure fan-out (eligible outgoing peer group only), a pure
397
- // fan-in (incoming only), or a MIXED fan-in/fan-out hub with both. Centering
398
- // optimizes over every ANCHORED peer-barycenter constraint at once rather
399
- // than letting whichever pass writes last win:
400
- // - one anchored constraint -> sit exactly on it
401
- // - two that disagree -> sit at their midpoint, the minimax-optimal
402
- // one-node placement (halves the worst offset, deterministic regardless
403
- // of peer counts or direction)
404
- // A free (downstream-re-spread) small fan-out is dropped from the set.
405
- //
406
- // SPECIAL CASE (issue #61) — a MIXED hub whose incoming and outgoing
407
- // barycenters disagree by more than the midpoint can absorb, with a LARGE
408
- // (4–6) terminal fan-out: the midpoint would leave both sides ~half the gap
409
- // off, and re-emitting the fan-out per edge would trade its clean shared trunk
410
- // for cramped stubs. Instead, rigidly shift the terminal out-group onto the
411
- // incoming barycenter (preserving its even spacing and shared trunk) and sit
412
- // the hub there too, so both sides are centered with the trunk intact. Falls
413
- // back to the midpoint below when shifting the out-group is unsafe.
414
- const MIXED_LARGE_GAP = 1.5
415
- const moveHub = (
416
- hub: PositionedNode | undefined,
417
- outPeers: PositionedNode[] | undefined,
418
- inPeers: PositionedNode[] | undefined,
419
- ): void => {
420
- // PORT_EXACT hubs (diamond/round/stadium/…) centre exactly like a rectangle:
421
- // the hub only translates on the cross axis and re-anchors through its exact
422
- // forward port, so a DECISION diamond fan-in/fan-out/mixed hub now squares up
423
- // on its peer barycenter instead of reverting to ELK's off-centre placement.
424
- // Composes with the passes that already touch diamonds: alignForkRejoinPeerCenters
425
- // (runs earlier; a diamond fork→3 rect peers→rejoin) leaves the hub ON its
426
- // barycenter, so this pass finds delta≈0 and no-ops there; applySymmetricFanoutEmissions
427
- // (runs later) re-spreads a small diamond fan-out around wherever the hub lands,
428
- // and a free small fan-out is dropped from the anchored set below so it doesn't
429
- // fight that pass.
430
- if (!hub || !PORT_EXACT.has(hub.shape) || nodeInsideGroups(hub, groups)) return
431
- const inBary = inPeers ? peerBarycenter(inPeers) : undefined
432
- const outBary = outPeers ? peerBarycenter(outPeers) : undefined
433
-
434
- if (inBary !== undefined && outBary !== undefined && outPeers!.length >= 4
435
- && isTerminalFanout(hub.id, outPeers!) && Math.abs(inBary - outBary) > MIXED_LARGE_GAP) {
436
- const outIds = new Set(outPeers!.map(p => p.id))
437
- const hubIds = new Set([hub.id])
438
- const outDelta = inBary - outBary
439
- const hubDelta = inBary - nodeCrossCenter(hub, graph.direction)
440
- if (crossShiftSafe(outIds, nodes, edges, graph.direction, outDelta, style)
441
- && crossShiftSafe(hubIds, nodes, edges, graph.direction, hubDelta, style)) {
442
- shiftNodeSetCross(outIds, nodes, edges, graph.direction, outDelta)
443
- if (Math.abs(hubDelta) > 0.5) shiftNodeSetCross(hubIds, nodes, edges, graph.direction, hubDelta)
444
- layoutDebug('[peer-barycenter] trunk-shift', hub.id, 'outs by', outDelta.toFixed(1))
445
- moved = true
446
- return
447
- }
448
- // Unsafe to shift the out-group: fall through to the midpoint below.
449
- }
450
-
451
- const anchored: number[] = []
452
- // Incoming source peers have no symmetric re-spread pass: always anchored.
453
- if (inBary !== undefined) anchored.push(inBary)
454
- if (outBary !== undefined && !(outPeers && smallTerminalFanout(hub.id, outPeers))) anchored.push(outBary)
455
- // No anchored side (only a free fan-out): fall back to centering on it.
456
- if (anchored.length === 0 && outBary !== undefined) anchored.push(outBary)
457
- if (anchored.length === 0) return
458
- const target = anchored.length === 1
459
- ? anchored[0]!
460
- : (Math.min(...anchored) + Math.max(...anchored)) / 2
461
- const delta = target - nodeCrossCenter(hub, graph.direction)
462
- if (Math.abs(delta) <= 0.5) return
463
- const movedIds = new Set([hub.id])
464
- if (!crossShiftSafe(movedIds, nodes, edges, graph.direction, delta, style)) return
465
- layoutDebug('[peer-barycenter] center', hub.id, 'by', delta.toFixed(1), anchored.length === 2 ? '(mixed)' : '')
466
- shiftNodeSetCross(movedIds, nodes, edges, graph.direction, delta)
467
- moved = true
468
- }
469
-
470
- // Process every hub once with all of its eligible constraints together, in
471
- // a deterministic id order, so a mixed hub is centered with both sides in
472
- // view rather than by whichever pass writes last.
473
- const hubIds = [...new Set([...bySource.keys(), ...byTarget.keys()])].sort()
474
- for (const hubId of hubIds) {
475
- const outgoing = bySource.get(hubId)
476
- const incoming = byTarget.get(hubId)
477
- const outPeers = outgoing ? candidatePeers(outgoing, 'target') : undefined
478
- const inPeers = incoming ? candidatePeers(incoming, 'source', corankFanin) : undefined
479
- moveHub(nodeMap.get(hubId), outPeers, inPeers)
480
- }
481
- if (!moved) break
482
- }
483
- }
484
-
485
- /**
486
- * Identify the spokes of every co-ranked mixed-label fan-in (the default
487
- * behavior, disabled by APL_NO_CORANK_FANIN; see layout-engine
488
- * corankFanInBalancingLabels).
489
- *
490
- * A fan-in hub fed by both a labeled and an unlabeled edge is co-ranked
491
- * pre-ELK so its sources share a rank; centerPeerBarycenters then squares up
492
- * the hub and the spokes converge as symmetric doglegs. Those bends are
493
- * JUSTIFIED — the convergence is structurally necessary and buys the symmetry —
494
- * so the spokes must be treated exactly like the fan-out bundle: skipped by the
495
- * hitch oracle and exempt from the bend penalty. This is the single predicate
496
- * both consumers (markCorankFanInBundles, alignLabeledSourcePort) share, so the
497
- * bend exemption, the hitch HARD-invariant, and the labeled-source-port pass all
498
- * AGREE on which spokes are part of a symmetric convergence.
499
- *
500
- * The gate mirrors corankFanInBalancingLabels (mixed labeled/unlabeled, rect hub
501
- * + rect ungrouped sources) AND additionally requires the sources to now sit on
502
- * the SAME flow layer (proving the co-rank succeeded) and be mutually
503
- * unreachable peers with one shared edge style — i.e. it only fires on a fan-in
504
- * the centering could actually square up. Returns hubId -> its incoming spoke
505
- * edges. Empty when disabled or no such fan-in exists. Pure/deterministic.
506
- */
507
- export function corankFanInSpokes(
508
- nodes: PositionedNode[],
509
- edges: PositionedEdge[],
510
- groups: PositionedGroup[],
511
- graph: MermaidGraph,
512
- ): Map<string, PositionedEdge[]> {
513
- const result = new Map<string, PositionedEdge[]>()
514
- if (layoutEnvFlag('APL_NO_CORANK_FANIN')) return result
515
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
516
- // The HUB may be any PORT_EXACT shape (a diamond/round/stadium mixed-label
517
- // fan-in co-ranks and centres just like a rectangle one, so its converging
518
- // spokes are the same justified symmetric convergence). markCorankFanInBundles
519
- // keeps every rebuilt endpoint on-outline (it verifies the spread hub entries
520
- // and collapses to the exact port for non-straight-sided hubs), so admitting a
521
- // non-rect hub never risks the offOutlineEndpoints HARD invariant.
522
- const isPortExactUngroupedHub = (n: PositionedNode | undefined): boolean =>
523
- !!n && PORT_EXACT.has(n.shape) && !nodeInsideGroups(n, groups)
524
- // The SOURCES stay rectangle-only, mirroring corankFanInBalancingLabels: a
525
- // non-rect (diamond/…) SOURCE emitting a single labelled line has its own
526
- // exact-vertex exit contract that a converging-bundle re-route would override
527
- // (issue #26 WS3 / alignPortLanes). Squaring up the hub never re-emits sources.
528
- const isRectUngrouped = (n: PositionedNode | undefined): boolean =>
529
- !!n && n.shape === 'rectangle' && !nodeInsideGroups(n, groups)
530
-
531
- const incoming = new Map<string, PositionedEdge[]>()
532
- for (const edge of edges) {
533
- if (edge.source === edge.target) continue
534
- if (!positionedEdgeForwardish(edge, nodeMap, graph.direction)) continue
535
- if (!incoming.has(edge.target)) incoming.set(edge.target, [])
536
- incoming.get(edge.target)!.push(edge)
537
- }
538
-
539
- for (const [hubId, group] of incoming) {
540
- if (group.length < 2 || group.length > 6) continue
541
- const hub = nodeMap.get(hubId)
542
- if (!isPortExactUngroupedHub(hub)) continue
543
- // MIXED hub only: some labeled, some not. A uniform fan-in already co-ranks
544
- // (and an all-unlabeled one is the plain bundler's job).
545
- const labeled = group.filter(e => !!e.label)
546
- if (labeled.length === 0 || labeled.length === group.length) continue
547
- // One shared edge style, mirroring the centering/bundling gate.
548
- const firstStyle = group[0]!.style
549
- if (group.some(e => e.style !== firstStyle)) continue
550
- // Every source must be a distinct, rectangle, ungrouped peer.
551
- const sources = group.map(e => nodeMap.get(e.source)).filter((n): n is PositionedNode => !!n)
552
- if (sources.length !== group.length) continue
553
- if (new Set(sources.map(s => s.id)).size !== sources.length) continue
554
- if (!sources.every(isRectUngrouped)) continue
555
- // The co-rank must actually have landed: the sources sit on one flow layer.
556
- if (!sameFlowLayer(sources, graph.direction, 28)) continue
557
- // Mutually-unreachable peers (the shape gate centerPeerBarycenters applies).
558
- let peers = true
559
- for (let i = 0; i < sources.length && peers; i++)
560
- for (let j = 0; j < sources.length; j++)
561
- if (i !== j && logicalGraphReaches(graph, sources[i]!.id, sources[j]!.id)) { peers = false; break }
562
- if (!peers) continue
563
- result.set(hubId, group)
564
- }
565
- return result
566
- }
567
-
568
- /**
569
- * Re-route every co-ranked mixed-label fan-in's spokes as clean, symmetric
570
- * converging doglegs and mark them bundle-owned — exactly as
571
- * applySymmetricFanoutEmissions does for the fan-out (re-route THEN mark).
572
- *
573
- * Runs after bundleEdgePaths (so it extends the same `bundled` set) and before
574
- * applyRouteContracts, which reads the set to stamp cert.invariant = 'bundle'.
575
- * That single marker makes findRouteHitches skip the spoke (no false HARD hitch)
576
- * AND the layout-rubric exempt its bend — the bend penalty and the HARD
577
- * hitch-invariant agree on what counts as a justified symmetric-convergence bend.
578
- *
579
- * Why re-route, not just mark: the plain bundler (bundleEdgePaths) skips any
580
- * group with a labeled edge, so a mixed fan-in keeps ELK's raw spoke geometry —
581
- * whose source endpoint can float off the node's port when a wide label reserves
582
- * a cell (a 'bundle' edge is not clipped/straightened downstream, so that float
583
- * would surface as an offOutlineEndpoints HARD violation). Rebuilding each spoke
584
- * from its source's forward PORT to a hub entry point spread symmetrically about
585
- * the hub's entry-side midpoint, sharing one elbow, gives a port-exact, mirror
586
- * dogleg — the same shape the unlabeled bundler produces.
587
- *
588
- * Conservative: a hub's spokes are only re-routed+marked when every rebuilt path
589
- * is clear of other nodes (mirroring bundleEdgePaths's clearance gate). If any
590
- * path is blocked, that hub is left entirely untouched — its spokes keep their
591
- * ELK route and the normal route-contract pass clips/certifies them, so the
592
- * hard-invariant is never put at risk. Deterministic: geometry derives only from
593
- * the frozen node positions and a fixed source ordering.
594
- */
595
- export function markCorankFanInBundles(
596
- nodes: PositionedNode[],
597
- edges: PositionedEdge[],
598
- groups: PositionedGroup[],
599
- graph: MermaidGraph,
600
- bundled: Set<PositionedEdge>,
601
- style: LabelMetricsStyle,
602
- ): void {
603
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
604
- const f = layoutFlow(graph.direction)
605
- for (const [hubId, spokes] of corankFanInSpokes(nodes, edges, groups, graph)) {
606
- const hub = nodeMap.get(hubId)!
607
- // Order spokes by source cross position so the rebuilt doglegs nest without
608
- // crossing (topmost source -> topmost hub entry, etc.). Deterministic.
609
- const ordered = [...spokes].sort((a, b) =>
610
- nodeCrossCenter(nodeMap.get(a.source)!, graph.direction) - nodeCrossCenter(nodeMap.get(b.source)!, graph.direction))
611
- const sources = ordered.map(e => nodeMap.get(e.source)!)
612
-
613
- // Hub entry points: spread symmetrically about the hub entry-side midpoint,
614
- // kept inside the side (spacing clamped so the outermost entry stays within
615
- // ~80% of the half-side). The entry side is the flow target side.
616
- const entryMid = shapePorts(hub)[f.targetSide]
617
- const hubCrossHalf = (f.isHorizontal ? hub.height : hub.width) / 2
618
- const maxSpan = hubCrossHalf * 1.6
619
- const spacing = Math.min(28, ordered.length > 1 ? maxSpan / (ordered.length - 1) : 0)
620
- let offsets = symmetricOffsets(ordered.length, spacing)
621
- // A spread entry is only ON the outline for a straight-sided (rect-like) hub.
622
- // On a diamond/round/stadium/… the flow side is a single vertex or a curve
623
- // tip, so an offset entry floats OFF the outline — and a bundle edge is not
624
- // clipped downstream, which would surface as an offOutlineEndpoints HARD. When
625
- // any spread entry misses the hub outline, collapse every spoke onto the exact
626
- // cardinal port (offset 0): the spokes still converge symmetrically through one
627
- // shared elbow to a single on-outline port (exactly what an unlabeled diamond
628
- // fan-in does) and the labels are still re-centred below.
629
- const entryAt = (offset: number): Point => f.isHorizontal
630
- ? { x: entryMid.x, y: entryMid.y + offset }
631
- : { x: entryMid.x + offset, y: entryMid.y }
632
- if (!offsets.every(o => onShapeOutline(hub, entryAt(o)))) offsets = offsets.map(() => 0)
633
-
634
- // Shared elbow on the main axis, midway between the rank of the (co-ranked)
635
- // sources' forward edge and the hub's entry edge — the converging junction.
636
- const sourceExitMain = f.isHorizontal
637
- ? (f.sourceSide === 'E' ? Math.max(...sources.map(s => s.x + s.width)) : Math.min(...sources.map(s => s.x)))
638
- : (f.sourceSide === 'S' ? Math.max(...sources.map(s => s.y + s.height)) : Math.min(...sources.map(s => s.y)))
639
- const hubEntryMain = f.isHorizontal ? entryMid.x : entryMid.y
640
- const elbow = sourceExitMain + (hubEntryMain - sourceExitMain) / 2
641
-
642
- const proposed: Array<{ edge: PositionedEdge; points: Point[] }> = []
643
- let clear = true
644
- for (let i = 0; i < ordered.length; i++) {
645
- const edge = ordered[i]!
646
- const src = sources[i]!
647
- const exit = shapePorts(src)[f.sourceSide]
648
- const entry = entryAt(offsets[i]!)
649
- const points = doglegBetween(exit, entry, graph.direction, elbow)
650
- if (!routeClearOfNodes(points, nodes, new Set([edge.source, edge.target]))) { clear = false; break }
651
- proposed.push({ edge, points })
652
- }
653
- if (!clear) continue // leave this hub's spokes to the normal route-contract path
654
-
655
- for (const { edge, points } of proposed) {
656
- edge.points = points
657
- edge.routeCertificate = undefined
658
- bundled.add(edge)
659
- }
660
- // Re-home the labeled spokes' pills onto their rebuilt routes (the same
661
- // helper bundleEdgePaths-fed labeled fan-outs use), THEN re-centre them onto
662
- // the route's main-axis midpoint: the co-rank dogleg otherwise leaves the
663
- // single labeled spoke hugging the source (the regression this fixes).
664
- assignBundledFanoutLabels(proposed, nodes, graph.direction, style, /* recenter */ true)
665
- }
666
- }
667
-
668
- export function alignForkRejoinPeerCenters(
669
- nodes: PositionedNode[],
670
- edges: PositionedEdge[],
671
- groups: PositionedGroup[],
672
- graph: MermaidGraph,
673
- style: LabelMetricsStyle,
674
- ): void {
675
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
676
- const bySource = new Map<string, PositionedEdge[]>()
677
- for (const edge of edges) {
678
- if (!positionedEdgeForwardish(edge, nodeMap, graph.direction)) continue
679
- if (!bySource.has(edge.source)) bySource.set(edge.source, [])
680
- bySource.get(edge.source)!.push(edge)
681
- }
682
- const forwardIndegree = (id: string): number => edges.filter(edge =>
683
- edge.target === id && positionedEdgeForwardish(edge, nodeMap, graph.direction)).length
684
- const forwardOutgoing = (id: string): PositionedEdge[] => edges.filter(edge =>
685
- edge.source === id && edge.target !== id && positionedEdgeForwardish(edge, nodeMap, graph.direction))
686
- const ownedForwardClosure = (root: PositionedNode, initial: ReadonlySet<string>): PositionedNode[] => {
687
- const out: PositionedNode[] = []
688
- const seen = new Set(initial)
689
- const queue = [root]
690
- while (queue.length > 0) {
691
- const node = queue.shift()!
692
- if (seen.has(node.id)) continue
693
- seen.add(node.id)
694
- out.push(node)
695
- for (const edge of forwardOutgoing(node.id)) {
696
- const next = nodeMap.get(edge.target)
697
- if (!next || seen.has(next.id)) continue
698
- if (forwardIndegree(next.id) === 1) queue.push(next)
699
- }
700
- }
701
- return out
702
- }
703
-
704
- for (const [sourceId, outgoing] of bySource) {
705
- if (outgoing.length !== 3) continue
706
- const source = nodeMap.get(sourceId)
707
- if (!source || source.shape !== 'diamond' || nodeInsideGroups(source, groups)) continue
708
- const sorted = [...outgoing].sort((a, b) => nodeCrossCenter(nodeMap.get(a.target)!, graph.direction) - nodeCrossCenter(nodeMap.get(b.target)!, graph.direction))
709
- const targets = sorted.map(edge => nodeMap.get(edge.target)).filter((node): node is PositionedNode => !!node)
710
- if (targets.length !== sorted.length) continue
711
- if (!targets.every(target => target.shape === 'rectangle' && !nodeInsideGroups(target, groups))) continue
712
- if (!sameFlowLayer(targets, graph.direction, 28)) continue
713
- let peer = true
714
- for (let i = 0; i < targets.length; i++) for (let j = 0; j < targets.length; j++) {
715
- if (i !== j && logicalGraphReaches(graph, targets[i]!.id, targets[j]!.id)) peer = false
716
- }
717
- if (!peer) continue
718
-
719
- const childOut = targets.map(target => forwardOutgoing(target.id))
720
- if (!childOut.every(list => list.length === 1)) continue
721
- const rejoinId = childOut[0]![0]!.target
722
- if (!childOut.every(list => list[0]!.target === rejoinId)) continue
723
- const rejoin = nodeMap.get(rejoinId)
724
- if (!rejoin || nodeInsideGroups(rejoin, groups)) continue
725
-
726
- const movedIds = new Set(targets.map(target => target.id))
727
- for (const node of ownedForwardClosure(rejoin, movedIds)) movedIds.add(node.id)
728
- if ([...movedIds].some(id => {
729
- const node = nodeMap.get(id)
730
- return !node || nodeInsideGroups(node, groups)
731
- })) continue
732
-
733
- const rowCenter = (nodeCrossCenter(targets[0]!, graph.direction) + nodeCrossCenter(targets[targets.length - 1]!, graph.direction)) / 2
734
- const delta = nodeCrossCenter(source, graph.direction) - rowCenter
735
- if (Math.abs(delta) <= 0.5) continue
736
- if (!crossShiftSafe(movedIds, nodes, edges, graph.direction, delta, style)) continue
737
- layoutDebug('[fork-rejoin] center', source.id, 'peer island by', delta.toFixed(1))
738
- shiftNodeSetCross(movedIds, nodes, edges, graph.direction, delta)
739
- }
740
- }
741
- function connectionPort(node: PositionedNode, side: 'N' | 'E' | 'S' | 'W' | DiamondFacet): Point {
742
- if (node.shape === 'diamond' && (side === 'NE' || side === 'SE' || side === 'SW' || side === 'NW')) return diamondFacetPorts(node)[side]
743
- return shapePorts(node)[side as 'N' | 'E' | 'S' | 'W']
744
- }
745
- function sourceEmissionPort(source: PositionedNode, direction: Direction, index: number, count: number): Point {
746
- if (source.shape === 'diamond') {
747
- const portSets: Partial<Record<Direction, Array<'N' | 'E' | 'S' | 'W' | DiamondFacet>>> = count === 2
748
- ? { LR: ['NE', 'SE'], RL: ['NW', 'SW'], BT: ['NW', 'NE'], TD: ['SW', 'SE'], TB: ['SW', 'SE'] }
749
- : count === 3
750
- ? { LR: ['NE', 'E', 'SE'], RL: ['NW', 'W', 'SW'], BT: ['NW', 'N', 'NE'], TD: ['SW', 'S', 'SE'], TB: ['SW', 'S', 'SE'] }
751
- : {}
752
- const port = portSets[direction]?.[index]
753
- if (port) return connectionPort(source, port)
754
- }
755
- const f = layoutFlow(direction)
756
- const t = (index + 1) / (count + 1)
757
- if (f.isHorizontal) return { x: f.sourceSide === 'E' ? source.x + source.width : source.x, y: source.y + source.height * t }
758
- return { x: source.x + source.width * t, y: f.sourceSide === 'S' ? source.y + source.height : source.y }
759
- }
760
- function targetFlowPort(target: PositionedNode, direction: Direction): Point {
761
- return shapePorts(target)[layoutFlow(direction).targetSide]
762
- }
763
- function symmetricOffsets(count: number, spacing: number): number[] {
764
- return Array.from({ length: count }, (_, i) => (i - (count - 1) / 2) * spacing)
765
- }
766
- function doglegBetween(source: Point, target: Point, direction: Direction, elbow: number): Point[] {
767
- const f = layoutFlow(direction)
768
- return f.isHorizontal
769
- ? simplifyPolyline([source, { x: elbow, y: source.y }, { x: elbow, y: target.y }, target])
770
- : simplifyPolyline([source, { x: source.x, y: elbow }, { x: target.x, y: elbow }, target])
771
- }
772
- function segmentClearOfNodes(a: Point, b: Point, nodes: PositionedNode[], exclude: ReadonlySet<string>, clearance = 1): boolean {
773
- const x1 = Math.min(a.x, b.x), x2 = Math.max(a.x, b.x)
774
- const y1 = Math.min(a.y, b.y), y2 = Math.max(a.y, b.y)
775
- for (const node of nodes) {
776
- if (exclude.has(node.id)) continue
777
- const nx1 = node.x - clearance, nx2 = node.x + node.width + clearance
778
- const ny1 = node.y - clearance, ny2 = node.y + node.height + clearance
779
- if (Math.abs(a.x - b.x) < 0.01) {
780
- if (a.x >= nx1 && a.x <= nx2 && y1 < ny2 && y2 > ny1) return false
781
- } else if (Math.abs(a.y - b.y) < 0.01) {
782
- if (a.y >= ny1 && a.y <= ny2 && x1 < nx2 && x2 > nx1) return false
783
- }
784
- }
785
- return true
786
- }
787
- function routeClearOfNodes(points: Point[], nodes: PositionedNode[], exclude: ReadonlySet<string>): boolean {
788
- for (let i = 1; i < points.length; i++) {
789
- if (!segmentClearOfNodes(points[i - 1]!, points[i]!, nodes, exclude)) return false
790
- }
791
- return true
792
- }
793
- export function honorLinkRankDistance(nodes: PositionedNode[], edges: PositionedEdge[], groups: PositionedGroup[], graph: MermaidGraph): void {
794
- if (!graph.edges.some(e => (e.length ?? 1) > 1)) return
795
- const classes = classifyRoutes(graph)
796
- const f = layoutFlow(graph.direction)
797
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
798
- const groupMap = new Map<string, PositionedGroup>()
799
- const flattenGroups = (gs: PositionedGroup[]): void => { for (const g of gs) { groupMap.set(g.id, g); flattenGroups(g.children) } }
800
- flattenGroups(groups)
801
-
802
- // A subgraph endpoint (`container` edge) expands to every node it encloses, so
803
- // shoving the unit moves the whole box. `cross-hierarchy` and `container` links
804
- // set rank distance just like `primary-forward`; only feedback (back) edges and
805
- // self-loops are excluded from the forward walk — a lengthened back edge is pure
806
- // styling and a cycle must never drag the lengthened edge's own source forward.
807
- const collectMembers = (sg: MermaidSubgraph, acc: string[]): void => {
808
- for (const id of sg.nodeIds) acc.push(id)
809
- for (const child of sg.children) collectMembers(child, acc)
810
- }
811
- const unitNodes = (id: string): string[] => {
812
- if (nodeMap.has(id)) return [id]
813
- const sg = findSubgraph(graph.subgraphs, id)
814
- if (!sg) return []
815
- const acc: string[] = []
816
- collectMembers(sg, acc)
817
- return acc
818
- }
819
-
820
- // Subgraph membership path (outermost → innermost) for every node id.
821
- const scopeChainOf = new Map<string, string[]>()
822
- const walkScopes = (sgs: MermaidSubgraph[], ancestors: string[]): void => {
823
- for (const sg of sgs) {
824
- const chain = [...ancestors, sg.id]
825
- for (const id of sg.nodeIds) scopeChainOf.set(id, chain)
826
- walkScopes(sg.children, chain)
827
- }
828
- }
829
- walkScopes(graph.subgraphs, [])
830
-
831
- // The rigid unit a lengthened link moves: an individual node when the link
832
- // stays within the endpoints' shared scope, but the whole enclosing subgraph
833
- // when the link crosses into it — a subgraph (especially one with its own
834
- // direction override) is laid out as a unit, so shoving one inner node along
835
- // the outer axis would shear its internal arrangement.
836
- const movableUnit = (src: string, tgt: string): string[] => {
837
- if (groupMap.has(tgt)) return unitNodes(tgt)
838
- const tgtChain = scopeChainOf.get(tgt) ?? []
839
- const srcScopes = new Set(scopeChainOf.get(src) ?? [])
840
- if (groupMap.has(src)) srcScopes.add(src)
841
- for (const sgId of tgtChain) if (!srcScopes.has(sgId)) return unitNodes(sgId)
842
- return [tgt]
843
- }
844
- const setsRankDistance = (i: number): boolean => {
845
- const cls = classes[i] ?? 'primary-forward'
846
- return cls === 'primary-forward' || cls === 'cross-hierarchy' || cls === 'container'
847
- }
848
-
849
- // Main-axis entry/exit of an endpoint that may be a node OR a subgraph box.
850
- const mainSpan = (id: string): { start: number; size: number } | null => {
851
- const n = nodeMap.get(id)
852
- if (n) return { start: n[f.main], size: nodeMainSize(n, graph.direction) }
853
- const g = groupMap.get(id)
854
- if (g) return { start: g[f.main], size: f.main === 'x' ? g.width : g.height }
855
- return null
856
- }
857
- const endpointEntry = (id: string): number | null => { const s = mainSpan(id); return s ? (f.sign === 1 ? s.start : s.start + s.size) : null }
858
- const endpointExit = (id: string): number | null => { const s = mainSpan(id); return s ? (f.sign === 1 ? s.start + s.size : s.start) : null }
859
- const isForwardish = (src: string, tgt: string): boolean => {
860
- const ex = endpointExit(src), en = endpointEntry(tgt)
861
- return ex !== null && en !== null && (en - ex) * f.sign > 0
862
- }
863
-
864
- // Forward adjacency over node ids, with subgraph endpoints expanded to members.
865
- const outgoing = new Map<string, string[]>()
866
- for (let i = 0; i < graph.edges.length; i++) {
867
- if (!setsRankDistance(i)) continue
868
- const edge = graph.edges[i]!
869
- if (!isForwardish(edge.source, edge.target)) continue
870
- for (const u of unitNodes(edge.source)) for (const v of unitNodes(edge.target)) {
871
- if (!outgoing.has(u)) outgoing.set(u, [])
872
- outgoing.get(u)!.push(v)
873
- }
874
- }
875
-
876
- const mainDelta = new Map<string, number>() // net main-axis shift applied per node
877
- const moveSet = (starts: string[], delta: number): void => {
878
- if (Math.abs(delta) < 0.5) return
879
- const seen = new Set<string>()
880
- const stack = [...starts]
881
- while (stack.length > 0) {
882
- const id = stack.pop()!
883
- if (seen.has(id)) continue
884
- seen.add(id)
885
- const n = nodeMap.get(id)
886
- if (n) {
887
- n[f.main] += delta
888
- mainDelta.set(id, (mainDelta.get(id) ?? 0) + delta)
889
- }
890
- for (const next of outgoing.get(id) ?? []) stack.push(next)
891
- }
892
- }
893
-
894
- // Capture each container's per-side insets (ELK's padding + label allowance)
895
- // before any node moves, so we can rebuild the box around the shifted members
896
- // afterwards without guessing the padding. Bottom-up so a parent box always
897
- // sees its children's rebuilt boxes.
898
- interface BoxBounds { x: number; y: number; r: number; b: number }
899
- interface Inset { l: number; t: number; r: number; b: number }
900
- const insetByGroup = new Map<string, Inset>()
901
- const groupMemberBounds = (group: PositionedGroup): BoxBounds | null => {
902
- let x = Infinity, y = Infinity, r = -Infinity, b = -Infinity
903
- const sg = findSubgraph(graph.subgraphs, group.id)
904
- for (const id of sg?.nodeIds ?? []) {
905
- const n = nodeMap.get(id)
906
- if (!n) continue
907
- x = Math.min(x, n.x); y = Math.min(y, n.y)
908
- r = Math.max(r, n.x + n.width); b = Math.max(b, n.y + n.height)
909
- }
910
- for (const child of group.children) {
911
- x = Math.min(x, child.x); y = Math.min(y, child.y)
912
- r = Math.max(r, child.x + child.width); b = Math.max(b, child.y + child.height)
913
- }
914
- return Number.isFinite(x) ? { x, y, r, b } : null
915
- }
916
- const recordGroupInsets = (group: PositionedGroup): void => {
917
- for (const child of group.children) recordGroupInsets(child)
918
- const bb = groupMemberBounds(group)
919
- if (bb) insetByGroup.set(group.id, { l: bb.x - group.x, t: bb.y - group.y, r: group.x + group.width - bb.r, b: group.y + group.height - bb.b })
920
- }
921
- const regrowGroup = (group: PositionedGroup): void => {
922
- for (const child of group.children) regrowGroup(child) // rebuild children first
923
- const bb = groupMemberBounds(group)
924
- const ins = insetByGroup.get(group.id)
925
- if (!bb || !ins) return
926
- group.x = bb.x - ins.l
927
- group.y = bb.y - ins.t
928
- group.width = bb.r + ins.r - group.x
929
- group.height = bb.b + ins.b - group.y
930
- }
931
- for (const group of groups) recordGroupInsets(group)
932
-
933
- // ELK hands us non-overlapping boxes; the shove must hand them on. A moved
934
- // node can land on (or sweep past) a node outside its downstream closure —
935
- // the boxes then overlap and everything near them degenerates (nodeOverlaps,
936
- // then edgeThroughNode; issue #81). Snapshot the overlapping pairs (the
937
- // rubric's >0.5px box predicate) before any move so the repair below can be
938
- // scoped to exactly the pairs this pass introduces.
939
- const boxesOverlap = (a: PositionedNode, b: PositionedNode): boolean => {
940
- const ox = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)
941
- const oy = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)
942
- return ox > 0.5 && oy > 0.5
943
- }
944
- const pairKey = (a: PositionedNode, b: PositionedNode): string => (a.id < b.id ? `${a.id}${b.id}` : `${b.id}${a.id}`)
945
- const overlappingPairs = (): Array<[PositionedNode, PositionedNode]> => {
946
- const pairs: Array<[PositionedNode, PositionedNode]> = []
947
- for (let i = 0; i < nodes.length; i++) {
948
- for (let j = i + 1; j < nodes.length; j++) {
949
- if (boxesOverlap(nodes[i]!, nodes[j]!)) pairs.push([nodes[i]!, nodes[j]!])
950
- }
951
- }
952
- return pairs
953
- }
954
- const preShovePairs = new Set(overlappingPairs().map(([a, b]) => pairKey(a, b)))
955
-
956
- for (let i = 0; i < graph.edges.length; i++) {
957
- if (!setsRankDistance(i)) continue
958
- const spec = graph.edges[i]!
959
- const length = spec.length ?? 1
960
- if (length <= 1) continue
961
- if (!isForwardish(spec.source, spec.target)) continue
962
- const exitS = endpointExit(spec.source), entryT = endpointEntry(spec.target)
963
- if (exitS === null || entryT === null) continue
964
- const currentGap = (entryT - exitS) * f.sign
965
- const minGap = DEFAULTS.layerSpacing + (length - 1) * (DEFAULTS.layerSpacing + 40)
966
- if (currentGap < minGap) moveSet(movableUnit(spec.source, spec.target), (minGap - currentGap) * f.sign)
967
- }
968
-
969
- // Treat the shove as physical: whatever it lands on is pushed ahead, never
970
- // sat on. The ahead node of each shove-introduced overlap moves further
971
- // forward (never backward — a backward move could re-compress a gap the loop
972
- // above just honoured), as a unit with its outermost subgraph that excludes
973
- // the other node. Forward-only motion cannot cycle, so the fixpoint
974
- // terminates; the round cap is a defensive bound, not an expected exit.
975
- const separationUnit = (id: string, other: string): string[] => {
976
- const otherScopes = new Set(scopeChainOf.get(other) ?? [])
977
- for (const sgId of scopeChainOf.get(id) ?? []) if (!otherScopes.has(sgId)) return unitNodes(sgId)
978
- return [id]
979
- }
980
- const pushAhead = (unit: string[], delta: number): void => {
981
- for (const id of unit) {
982
- const n = nodeMap.get(id)
983
- if (!n) continue
984
- n[f.main] += delta
985
- mainDelta.set(id, (mainDelta.get(id) ?? 0) + delta)
986
- }
987
- }
988
- for (let round = 0; round < nodes.length * 4; round++) {
989
- const created = overlappingPairs().filter(([a, b]) => !preShovePairs.has(pairKey(a, b)))
990
- if (created.length === 0) break
991
- created.sort((p, q) => (pairKey(p[0], p[1]) < pairKey(q[0], q[1]) ? -1 : 1))
992
- const [a, b] = created[0]!
993
- const flowGap = (nodeMainCenter(a, graph.direction) - nodeMainCenter(b, graph.direction)) * f.sign
994
- const [behind, ahead] = (flowGap !== 0 ? flowGap > 0 : a.id > b.id) ? [b, a] : [a, b]
995
- const delta = f.sign === 1
996
- ? behind[f.main] + nodeMainSize(behind, graph.direction) + DEFAULTS.nodeSpacing - ahead[f.main]
997
- : ahead[f.main] + nodeMainSize(ahead, graph.direction) + DEFAULTS.nodeSpacing - behind[f.main]
998
- pushAhead(separationUnit(ahead.id, behind.id), delta * f.sign)
999
- }
1000
-
1001
- // Grow/translate every container so it still encloses its (possibly moved) members.
1002
- for (const group of groups) regrowGroup(group)
1003
-
1004
- // Feedback edges escape to a lane just outside the node band on the
1005
- // max-cross side (matching ELK's deterministic feedbackEdges placement). The
1006
- // lane must clear every node so the back edge never grazes an intermediate
1007
- // box after the gap widens.
1008
- const FEEDBACK_LANE_GAP = 10
1009
- const crossMaxEdge = (n: PositionedNode): number => nodeCrossCenter(n, graph.direction) + nodeCrossSize(n, graph.direction) / 2
1010
- const crossMinEdge = (n: PositionedNode): number => nodeCrossCenter(n, graph.direction) - nodeCrossSize(n, graph.direction) / 2
1011
- const laneCross = Math.max(...nodes.map(crossMaxEdge)) + FEEDBACK_LANE_GAP
1012
- const laneCrossMin = Math.min(...nodes.map(crossMinEdge)) - FEEDBACK_LANE_GAP
1013
- const point = (mainV: number, crossV: number): Point =>
1014
- f.main === 'x' ? { x: mainV, y: crossV } : { x: crossV, y: mainV }
1015
-
1016
- // Attach point on the flow side of an endpoint that may be a node or a box.
1017
- const flowPort = (id: string, side: 'N' | 'E' | 'S' | 'W'): Point | null => {
1018
- const n = nodeMap.get(id)
1019
- if (n) return shapePorts(n)[side]
1020
- const g = groupMap.get(id)
1021
- if (!g) return null
1022
- const cx = g.x + g.width / 2, cy = g.y + g.height / 2
1023
- switch (side) {
1024
- case 'E': return { x: g.x + g.width, y: cy }
1025
- case 'W': return { x: g.x, y: cy }
1026
- case 'S': return { x: cx, y: g.y + g.height }
1027
- default: return { x: cx, y: g.y }
1028
- }
1029
- }
1030
-
1031
- // Free-channel centers: midpoints of the gaps between sorted box boundaries
1032
- // along an axis — deterministic, obstacle-derived homes for detour segments
1033
- // when a canonical route below is blocked. Intervals an orthogonal segment
1034
- // cannot actually fit through (≤2px, given the 1px route clearance) are
1035
- // skipped; blocked candidates are weeded out by routeClearOfNodes anyway.
1036
- const channelCenters = (axis: 'x' | 'y'): number[] => {
1037
- const stops = new Set<number>()
1038
- for (const n of nodes) { stops.add(n[axis]); stops.add(n[axis] + (axis === 'x' ? n.width : n.height)) }
1039
- const sorted = [...stops].sort((a, b) => a - b)
1040
- const centers: number[] = []
1041
- for (let i = 1; i < sorted.length; i++) if (sorted[i]! - sorted[i - 1]! > 2) centers.push((sorted[i]! + sorted[i - 1]!) / 2)
1042
- return centers
1043
- }
1044
- const crossAxis: 'x' | 'y' = f.main === 'x' ? 'y' : 'x'
1045
- const byProximity = (values: number[], anchor: number): number[] =>
1046
- [...values].sort((a, b) => Math.abs(a - anchor) - Math.abs(b - anchor) || a - b).slice(0, 16)
1047
-
1048
- // Reconnect every edge after the rank-distance move so no endpoint is left
1049
- // pointing at a node's pre-move position (which would trip the route-staleness
1050
- // certifier). Primary-forward / cross-hierarchy / container links get a forward
1051
- // dogleg between their (node or box) ports; feedback (back) edges re-loop
1052
- // through the outer lane; self-loops ride along with their moved node.
1053
- // applyRouteContracts certifies the final geometry.
1054
- for (const edge of edges) {
1055
- const cls = edge.edgeIndex !== undefined ? (classes[edge.edgeIndex] ?? 'primary-forward') : 'primary-forward'
1056
- if (cls === 'self-loop') {
1057
- const delta = mainDelta.get(edge.source) ?? 0
1058
- if (delta !== 0) for (const p of edge.points) p[f.main] += delta
1059
- if (delta !== 0 && edge.labelPosition) edge.labelPosition[f.main] += delta
1060
- continue
1061
- }
1062
- if (cls === 'feedback') {
1063
- const source = nodeMap.get(edge.source), target = nodeMap.get(edge.target)
1064
- if (!source || !target) continue
1065
- // Feedback U-detour: drop to the outer lane at the source, run back along
1066
- // it, and rise into the target — both anchored at the max-cross side. A
1067
- // riser can cross a bystander sitting between the node and the lane
1068
- // (issue #81's no-overlap residual). When — and only when — the max-side
1069
- // U is blocked, fall back deterministically: the min-side lane, then a
1070
- // rung variant per lane that leaves the lane at a free channel and
1071
- // approaches the blocked endpoint through a clear column (the center
1072
- // anchors stay on-outline for every shape, unlike sliding a riser
1073
- // off-center). If everything is blocked, keep the canonical max-side
1074
- // route for the post-freeze net to repair.
1075
- const mSrc = nodeMainCenter(source, graph.direction), mTgt = nodeMainCenter(target, graph.direction)
1076
- const uDetour = (lane: number, edgeCross: (n: PositionedNode) => number): Point[] => [
1077
- point(mSrc, edgeCross(source)), point(mSrc, lane), point(mTgt, lane), point(mTgt, edgeCross(target)),
1078
- ]
1079
- const rungTarget = (lane: number, edgeCross: (n: PositionedNode) => number, mRung: number, col: number): Point[] => [
1080
- point(mSrc, edgeCross(source)), point(mSrc, lane), point(mRung, lane), point(mRung, col), point(mTgt, col), point(mTgt, edgeCross(target)),
1081
- ]
1082
- const rungSource = (lane: number, edgeCross: (n: PositionedNode) => number, mRung: number, col: number): Point[] => [
1083
- point(mSrc, edgeCross(source)), point(mSrc, col), point(mRung, col), point(mRung, lane), point(mTgt, lane), point(mTgt, edgeCross(target)),
1084
- ]
1085
- const ends = new Set([edge.source, edge.target])
1086
- const clear = (pts: Point[]): boolean => routeClearOfNodes(simplifyPolyline(pts), nodes, ends)
1087
- const lanes: Array<[number, (n: PositionedNode) => number]> = [[laneCross, crossMaxEdge], [laneCrossMin, crossMinEdge]]
1088
- let route: Point[] | undefined
1089
- for (const [lane, edgeCross] of lanes) {
1090
- const candidate = uDetour(lane, edgeCross)
1091
- if (clear(candidate)) { route = candidate; break }
1092
- }
1093
- if (!route) {
1094
- const rungMains = byProximity(channelCenters(f.main === 'x' ? 'x' : 'y'), mTgt)
1095
- search: for (const [lane, edgeCross] of lanes) {
1096
- const cols = byProximity(channelCenters(crossAxis), edgeCross(target))
1097
- for (const build of [rungTarget, rungSource]) {
1098
- for (const mRung of rungMains) {
1099
- for (const col of cols) {
1100
- const candidate = build(lane, edgeCross, mRung, col)
1101
- if (clear(candidate)) { route = simplifyPolyline(candidate); break search }
1102
- }
1103
- }
1104
- }
1105
- }
1106
- }
1107
- edge.points = route ?? uDetour(laneCross, crossMaxEdge)
1108
- if (edge.label) edge.labelPosition = calculatePathMidpoint(edge.points)
1109
- continue
1110
- }
1111
- const start = flowPort(edge.source, f.sourceSide)
1112
- const end = flowPort(edge.target, f.targetSide)
1113
- if (!start || !end) continue
1114
- // A midpoint elbow can put the dogleg's cross run or its entry riser
1115
- // through a bystander box. When — and only when — the midpoint dogleg is
1116
- // blocked, fall back deterministically: single elbows at free-channel
1117
- // centers between the ports (nearest the midpoint first), then a
1118
- // double-elbow staircase whose cross run rides a free column (the rep2
1119
- // shape in eval/degenerate-etn: a bystander sitting over the target's
1120
- // entry port, threadable only through the channel beside it). If every
1121
- // variant is blocked, keep the midpoint for the post-freeze net to repair.
1122
- const ends = new Set([edge.source, edge.target])
1123
- const clear = (pts: Point[]): boolean => routeClearOfNodes(pts, nodes, ends)
1124
- const lo = Math.min(start[f.main], end[f.main]), hi = Math.max(start[f.main], end[f.main])
1125
- let route = doglegBetween(start, end, graph.direction, (lo + hi) / 2)
1126
- if (!clear(route)) {
1127
- let found: Point[] | undefined
1128
- const mains = byProximity(channelCenters(f.main === 'x' ? 'x' : 'y').filter(m => m > lo && m < hi), (lo + hi) / 2)
1129
- for (const m of mains) {
1130
- const candidate = doglegBetween(start, end, graph.direction, m)
1131
- if (clear(candidate)) { found = candidate; break }
1132
- }
1133
- if (!found) {
1134
- const cS = start[crossAxis], cE = end[crossAxis]
1135
- const cols = byProximity(channelCenters(crossAxis), (cS + cE) / 2)
1136
- staircase: for (const col of cols) {
1137
- for (const m1 of mains) {
1138
- for (const m2 of byProximity(mains.filter(m => (m - m1) * f.sign > 0), end[f.main])) {
1139
- const candidate = simplifyPolyline([start, point(m1, cS), point(m1, col), point(m2, col), point(m2, cE), end])
1140
- if (clear(candidate)) { found = candidate; break staircase }
1141
- }
1142
- }
1143
- }
1144
- }
1145
- if (found) route = found
1146
- }
1147
- edge.points = route
1148
- if (edge.label) edge.labelPosition = calculatePathMidpoint(edge.points)
1149
- }
1150
- }
1151
- /**
1152
- * Post-freeze safety net for STALE edge endpoints. A pass that MOVES a node is
1153
- * responsible for re-anchoring the edges incident to it (honorLinkRankDistance,
1154
- * above, rebuilds its own); but equalizePeerNodeDimensions repositions a fan-in
1155
- * peer without touching a cone edge INTO it (an upstream `U --> S` where S also
1156
- * fans into a hub), so that edge is left pointing at S's pre-move position and
1157
- * its endpoint dangles OFF the moved outline — offOutlineEndpoints (or, if it
1158
- * grazes a neighbour, edgeThroughNode). applyRouteContracts certifies the stale
1159
- * route rather than re-anchoring it. After the freeze, re-route any edge whose
1160
- * endpoint sits off its node's outline as an orthogonal dogleg between the two
1161
- * nodes' flow-side ports, landing the endpoint back on the outline.
1162
- *
1163
- * Fires ONLY on an already-off-outline endpoint — which the HARD-clean corpus
1164
- * never has — so it is a strict no-op there and byte-exact equivalence holds. A
1165
- * rebuilt route is adopted only when it clears every other node (an on-outline
1166
- * endpoint is not worth trading for an edgeThroughNode); self-loops are skipped
1167
- * (their stub is the renderer's). Deterministic; label re-centred on the new route.
1168
- */
1169
- export function reanchorOffOutlineEndpoints(nodes: PositionedNode[], edges: PositionedEdge[], graph: MermaidGraph): void {
1170
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
1171
- const f = layoutFlow(graph.direction)
1172
- for (const edge of edges) {
1173
- if (edge.source === edge.target || edge.points.length < 2) continue
1174
- const source = nodeMap.get(edge.source), target = nodeMap.get(edge.target)
1175
- if (!source || !target) continue
1176
- const srcOff = !onShapeOutline(source, edge.points[0]!)
1177
- const tgtOff = !onShapeOutline(target, edge.points[edge.points.length - 1]!)
1178
- if (!srcOff && !tgtOff) continue
1179
- const start = shapePorts(source)[f.sourceSide]
1180
- const end = shapePorts(target)[f.targetSide]
1181
- const route = doglegBetween(start, end, graph.direction, (start[f.main] + end[f.main]) / 2)
1182
- if (!routeClearOfNodes(route, nodes, new Set([edge.source, edge.target]))) continue
1183
- const savedCert = edge.routeCertificate
1184
- edge.points = route
1185
- recertifyReroutedEdge(edge, savedCert, source, target)
1186
- if (edge.label) edge.labelPosition = calculatePathMidpoint(route)
1187
- }
1188
- }
1189
- /**
1190
- * Post-freeze safety net for the NODE-MOVE -> edgeThroughNode class — the
1191
- * on-outline-endpoint sibling of reanchorOffOutlineEndpoints (above). A pass that
1192
- * MOVES a node must keep the edges incident to it clear of every OTHER node, but
1193
- * honorLinkRankDistance rebuilds a lengthened skip link, and alignPortLanes
1194
- * straightens a feedback edge, as a plain dogleg WITHOUT a clearance check
1195
- * (unlike applySymmetricFanoutEmissions): a link that skips a collinear node
1196
- * (`A ===> C` over B) or a feedback whose return lane a peer moved into
1197
- * (`T1 --> S1` past S2) is left running straight THROUGH that node.
1198
- * applyRouteContracts cannot always repair it — its Z has no in-span escape lane
1199
- * when the obstacle is as tall as the endpoints, and its escape detour's exit stub
1200
- * is rejected as a channel conflict when a sibling shares the source lane — so the
1201
- * through-node route is certified as-is. Its endpoints stay port-exact (ON the
1202
- * outline), which is why reanchorOffOutlineEndpoints, keyed on OFF-outline
1203
- * endpoints, never sees it.
1204
- *
1205
- * After the freeze, re-route any edge whose polyline passes through a non-endpoint
1206
- * node (the rubric's exact edgeThroughNode predicate). The endpoints are kept —
1207
- * they are correct ports — and only the interior is detoured around the obstacle:
1208
- * first by sliding a blocked INTERIOR segment sideways to the nearest parallel lane
1209
- * that clears every node (fixes a feedback U-route a peer intruded on, preserving
1210
- * its shape), else by BRACKETING the route over/under the obstacle band between its
1211
- * two ports (fixes a straight skip link through a collinear node). A detour is
1212
- * adopted ONLY when it clears every node and both endpoints stay on their outlines;
1213
- * otherwise the edge is left untouched (never traded for a worse route).
1214
- *
1215
- * Fires ONLY on an edge the rubric already flags edgeThroughNode — which the
1216
- * HARD-clean corpus never has — so it is a strict no-op there and byte-exact
1217
- * equivalence holds. Edge-only, deterministic (fixed candidate order); label
1218
- * re-centred on the new route. Self-loops are skipped.
1219
- */
1220
- export function rerouteEdgesThroughNodes(nodes: PositionedNode[], edges: PositionedEdge[], graph: MermaidGraph): void {
1221
- const f = layoutFlow(graph.direction)
1222
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
1223
- const THROUGH_LANE_GAP = 10
1224
- const throughNode = (pts: Point[], skip: ReadonlySet<string>): boolean => {
1225
- for (const n of nodes) {
1226
- if (skip.has(n.id)) continue
1227
- for (let i = 1; i < pts.length; i++) if (segmentThroughShape(pts[i - 1]!, pts[i]!, n)) return true
1228
- }
1229
- return false
1230
- }
1231
- // Slide a blocked INTERIOR (non-terminal) axis-aligned segment sideways to the
1232
- // nearest parallel lane clear of every node, keeping the polyline's endpoints
1233
- // and orthogonal shape. Only shifts when both neighbours are perpendicular to
1234
- // the segment (so the slide cannot bend a neighbour into a diagonal).
1235
- const shiftInteriorSegment = (pts: Point[], skip: ReadonlySet<string>): Point[] | null => {
1236
- for (let k = 1; k <= pts.length - 3; k++) {
1237
- const a = pts[k]!, b = pts[k + 1]!, before = pts[k - 1]!, after = pts[k + 2]!
1238
- const vertical = Math.abs(a.x - b.x) < 0.5, horizontal = Math.abs(a.y - b.y) < 0.5
1239
- if (vertical === horizontal) continue // diagonal or degenerate — leave it
1240
- if (!throughNode([a, b], skip)) continue // not the offending segment
1241
- const ax: 'x' | 'y' = vertical ? 'x' : 'y'
1242
- const perp: 'x' | 'y' = vertical ? 'y' : 'x'
1243
- if (Math.abs(before[perp] - a[perp]) > 0.5 || Math.abs(after[perp] - b[perp]) > 0.5) continue
1244
- const c = a[ax]
1245
- const cands: number[] = []
1246
- for (const n of nodes) {
1247
- if (skip.has(n.id)) continue
1248
- cands.push(n[ax] - THROUGH_LANE_GAP, n[ax] + (ax === 'x' ? n.width : n.height) + THROUGH_LANE_GAP)
1249
- }
1250
- cands.sort((p, q) => Math.abs(p - c) - Math.abs(q - c))
1251
- for (const cp of cands) {
1252
- const trial = pts.map(p => ({ ...p }))
1253
- trial[k]![ax] = cp; trial[k + 1]![ax] = cp
1254
- if (!throughNode(trial, skip) && routeClearOfNodes(trial, nodes, skip)) return simplifyPolyline(trial)
1255
- }
1256
- }
1257
- return null
1258
- }
1259
- // Bracket a route over/under an obstacle, exiting the source and entering the
1260
- // target along the flow axis: a short stub off each port, a jog to a clear
1261
- // cross-lane, and the corridor run between. Keeps both ports. Candidate lanes
1262
- // are just past EACH corridor obstacle's cross-edges (so a lane in the gap
1263
- // between two obstacles is reachable, e.g. a feedback that must pass between the
1264
- // target and an unrelated node beside it) plus the whole-band edges as a
1265
- // fallback — nearest the ports first. This handles both the forward skip link
1266
- // and the feedback U-route whose return segment cut a node beside the target.
1267
- const bracketOverBand = (pts: Point[], skip: ReadonlySet<string>): Point[] | null => {
1268
- const start = pts[0]!, end = pts[pts.length - 1]!
1269
- const mLo = Math.min(start[f.main], end[f.main]), mHi = Math.max(start[f.main], end[f.main])
1270
- let bandLo = Infinity, bandHi = -Infinity
1271
- const laneSet: number[] = []
1272
- for (const n of nodes) {
1273
- if (skip.has(n.id)) continue
1274
- const ns = n[f.main], ne = n[f.main] + (f.main === 'x' ? n.width : n.height)
1275
- if (ne <= mLo || ns >= mHi) continue // outside the corridor
1276
- const cs = nodeCrossStart(n, graph.direction), ce = cs + nodeCrossSize(n, graph.direction)
1277
- bandLo = Math.min(bandLo, cs); bandHi = Math.max(bandHi, ce)
1278
- laneSet.push(cs - THROUGH_LANE_GAP, ce + THROUGH_LANE_GAP) // just past this obstacle's edges
1279
- }
1280
- if (!Number.isFinite(bandLo)) return null
1281
- laneSet.push(bandLo - THROUGH_LANE_GAP, bandHi + THROUGH_LANE_GAP) // whole-band fallback
1282
- const pt = (mainV: number, crossV: number): Point => (f.main === 'x' ? { x: mainV, y: crossV } : { x: crossV, y: mainV })
1283
- const dir = Math.sign(end[f.main] - start[f.main]) || 1
1284
- const stub = Math.min(12, Math.abs(end[f.main] - start[f.main]) / 3)
1285
- const m1 = start[f.main] + dir * stub, m2 = end[f.main] - dir * stub
1286
- const mean = (start[f.cross] + end[f.cross]) / 2
1287
- const lanes = [...new Set(laneSet)].sort((p, q) => Math.abs(p - mean) - Math.abs(q - mean))
1288
- for (const lane of lanes) {
1289
- const route = simplifyPolyline([start, pt(m1, start[f.cross]), pt(m1, lane), pt(m2, lane), pt(m2, end[f.cross]), end])
1290
- if (!throughNode(route, skip) && routeClearOfNodes(route, nodes, skip)) return route
1291
- }
1292
- return null
1293
- }
1294
- for (const edge of edges) {
1295
- if (edge.source === edge.target || edge.points.length < 2) continue
1296
- const source = nodeMap.get(edge.source), target = nodeMap.get(edge.target)
1297
- if (!source || !target) continue
1298
- const skip = new Set([edge.source, edge.target])
1299
- if (!throughNode(edge.points, skip)) continue // no-op gate: only fires on edgeThroughNode
1300
- // Off-outline endpoints are reanchorOffOutlineEndpoints' job; this pass only
1301
- // relocates the interior of an otherwise port-exact route.
1302
- if (!onShapeOutline(source, edge.points[0]!) || !onShapeOutline(target, edge.points[edge.points.length - 1]!)) continue
1303
- const route = shiftInteriorSegment(edge.points, skip) ?? bracketOverBand(edge.points, skip)
1304
- if (!route) continue // never-worse: no clean detour found, leave as-is
1305
- const savedCert = edge.routeCertificate
1306
- edge.points = route
1307
- recertifyReroutedEdge(edge, savedCert, source, target)
1308
- if (edge.label) edge.labelPosition = calculatePathMidpoint(route)
1309
- }
1310
- }
1311
- function shiftTerminalRunCross(edge: PositionedEdge, direction: Direction, fromEnd: boolean, delta: number): void {
1312
- if (edge.points.length === 0) return
1313
- const f = layoutFlow(direction)
1314
- const idx = fromEnd ? edge.points.length - 1 : 0
1315
- const step = fromEnd ? -1 : 1
1316
- const ref = edge.points[idx]![f.cross]
1317
- let mainLo = Infinity
1318
- let mainHi = -Infinity
1319
- for (let i = idx; i >= 0 && i < edge.points.length; i += step) {
1320
- if (Math.abs(edge.points[i]![f.cross] - ref) > 0.5) break
1321
- mainLo = Math.min(mainLo, edge.points[i]![f.main])
1322
- mainHi = Math.max(mainHi, edge.points[i]![f.main])
1323
- edge.points[i]![f.cross] += delta
1324
- }
1325
- if (edge.labelPosition &&
1326
- Math.abs(edge.labelPosition[f.cross] - ref) <= 12 &&
1327
- edge.labelPosition[f.main] >= mainLo - 0.5 &&
1328
- edge.labelPosition[f.main] <= mainHi + 0.5) {
1329
- edge.labelPosition[f.cross] += delta
1330
- }
1331
- }
1332
- function shiftNodeSetCross(
1333
- movedIds: ReadonlySet<string>,
1334
- nodes: PositionedNode[],
1335
- edges: PositionedEdge[],
1336
- direction: Direction,
1337
- delta: number,
1338
- ): void {
1339
- const f = layoutFlow(direction)
1340
- for (const node of nodes) {
1341
- if (!movedIds.has(node.id)) continue
1342
- node[f.cross] += delta
1343
- }
1344
- for (const edge of edges) {
1345
- const srcMoved = movedIds.has(edge.source)
1346
- const tgtMoved = movedIds.has(edge.target)
1347
- if (srcMoved && tgtMoved) {
1348
- for (const point of edge.points) point[f.cross] += delta
1349
- if (edge.labelPosition) edge.labelPosition[f.cross] += delta
1350
- } else if (tgtMoved) {
1351
- shiftTerminalRunCross(edge, direction, true, delta)
1352
- } else if (srcMoved) {
1353
- shiftTerminalRunCross(edge, direction, false, delta)
1354
- }
1355
- }
1356
- }
1357
- function crossShiftSafe(
1358
- movedIds: ReadonlySet<string>,
1359
- nodes: PositionedNode[],
1360
- edges: PositionedEdge[],
1361
- direction: Direction,
1362
- delta: number,
1363
- style: LabelMetricsStyle,
1364
- ): boolean {
1365
- const f = layoutFlow(direction)
1366
- const moved = nodes.filter(node => movedIds.has(node.id))
1367
- const shifted = new Map(moved.map(node => [node.id, {
1368
- x: f.isHorizontal ? node.x : node.x + delta,
1369
- y: f.isHorizontal ? node.y + delta : node.y,
1370
- width: node.width,
1371
- height: node.height,
1372
- }]))
1373
- for (const box of shifted.values()) {
1374
- if (box.x < 0 || box.y < 0) return false
1375
- }
1376
- for (let i = 0; i < moved.length; i++) {
1377
- const a = shifted.get(moved[i]!.id)!
1378
- for (let j = i + 1; j < moved.length; j++) {
1379
- if (rectsOverlap(a, shifted.get(moved[j]!.id)!, 8)) return false
1380
- }
1381
- for (const other of nodes) {
1382
- if (movedIds.has(other.id)) continue
1383
- if (rectsOverlap(a, other, 8)) return false
1384
- }
1385
- }
1386
- for (const node of moved) {
1387
- const box = shifted.get(node.id)!
1388
- for (const edge of edges) {
1389
- if (movedIds.has(edge.source) || movedIds.has(edge.target)) continue
1390
- for (let i = 1; i < edge.points.length; i++) {
1391
- const a = edge.points[i - 1]!, b = edge.points[i]!
1392
- if (Math.max(a.x, b.x) > box.x + 0.5 && Math.min(a.x, b.x) < box.x + box.width - 0.5 &&
1393
- Math.max(a.y, b.y) > box.y + 0.5 && Math.min(a.y, b.y) < box.y + box.height - 0.5) return false
1394
- }
1395
- const rect = labelRect(edge, style)
1396
- if (rect && rectsOverlap(box, { x: rect.x, y: rect.y, width: rect.w, height: rect.h }, 8)) return false
1397
- }
1398
- }
1399
- return true
1400
- }
1401
- export function collapseTinyBundledHitches(nodes: PositionedNode[], edges: PositionedEdge[], bundled: Set<PositionedEdge>): void {
1402
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
1403
- for (const edge of edges) {
1404
- if (!bundled.has(edge) || edge.label || edge.points.length !== 4) continue
1405
- const [a, b, c, d] = edge.points
1406
- if (!a || !b || !c || !d) continue
1407
- const target = nodeMap.get(edge.target)
1408
- if (!target || target.shape !== 'rectangle') continue
1409
- const exclude = new Set([edge.source, edge.target])
1410
- if (Math.abs(a.y - b.y) < 0.01 && Math.abs(b.x - c.x) < 0.01 && Math.abs(c.y - d.y) < 0.01 && Math.abs(b.y - c.y) <= 6) {
1411
- const end = { x: d.x, y: a.y }
1412
- const onTargetSide = Math.abs(d.x - target.x) < 0.01 || Math.abs(d.x - (target.x + target.width)) < 0.01
1413
- if (onTargetSide && end.y >= target.y - 0.5 && end.y <= target.y + target.height + 0.5 && routeClearOfNodes([a, end], nodes, exclude)) {
1414
- edge.points = [a, end]
1415
- edge.routeCertificate = undefined
1416
- }
1417
- continue
1418
- }
1419
- if (Math.abs(a.x - b.x) < 0.01 && Math.abs(b.y - c.y) < 0.01 && Math.abs(c.x - d.x) < 0.01 && Math.abs(b.x - c.x) <= 6) {
1420
- const end = { x: a.x, y: d.y }
1421
- const onTargetSide = Math.abs(d.y - target.y) < 0.01 || Math.abs(d.y - (target.y + target.height)) < 0.01
1422
- if (onTargetSide && end.x >= target.x - 0.5 && end.x <= target.x + target.width + 0.5 && routeClearOfNodes([a, end], nodes, exclude)) {
1423
- edge.points = [a, end]
1424
- edge.routeCertificate = undefined
1425
- }
1426
- }
1427
- }
1428
- }
1429
- function labelBoxAt(label: string, center: Point, style: LabelMetricsStyle): { x: number; y: number; width: number; height: number } {
1430
- const metrics = measureMultilineText(label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
1431
- return {
1432
- x: center.x - (metrics.width + 16) / 2,
1433
- y: center.y - (metrics.height + 16) / 2,
1434
- width: metrics.width + 16,
1435
- height: metrics.height + 16,
1436
- }
1437
- }
1438
- function longestSegmentMidpoint(points: Point[]): Point {
1439
- let best: [Point, Point] = [points[0]!, points[points.length - 1]!]
1440
- let bestLength = -1
1441
- for (let i = 1; i < points.length; i++) {
1442
- const a = points[i - 1]!, b = points[i]!
1443
- const length = Math.hypot(b.x - a.x, b.y - a.y)
1444
- if (length > bestLength) { bestLength = length; best = [a, b] }
1445
- }
1446
- return { x: (best[0].x + best[1].x) / 2, y: (best[0].y + best[1].y) / 2 }
1447
- }
1448
- interface BundledLabelCandidate {
1449
- point: Point
1450
- box: { x: number; y: number; width: number; height: number }
1451
- rank: number
1452
- pathDistance: number
1453
- routeMidpointDistance: number
1454
- segmentMidpointDistance: number
1455
- straightRunPenalty: number
1456
- terminalSegmentPenalty: number
1457
- }
1458
- function readableLabelGap(style: LabelMetricsStyle): number {
1459
- return Math.max(8, style.edgeLabelFontSize * 0.75)
1460
- }
1461
- function labelPortStubGap(style: LabelMetricsStyle): number {
1462
- return Math.max(12, style.edgeLabelFontSize)
1463
- }
1464
- function terminalMarkerGap(style: LabelMetricsStyle, edge: PositionedEdge): number {
1465
- const lineWidth = (style as LabelMetricsStyle & { lineWidth?: number }).lineWidth ?? 1
1466
- const strokeWidth = edge.style === 'thick' ? lineWidth * 2 : lineWidth
1467
- return Math.max(18, style.edgeLabelFontSize + ARROW_HEAD.width + strokeWidth * 2)
1468
- }
1469
- function maxTerminalMarkerGap(style: LabelMetricsStyle): number {
1470
- const lineWidth = (style as LabelMetricsStyle & { lineWidth?: number }).lineWidth ?? 1
1471
- const maxStrokeWidth = lineWidth * 2
1472
- return Math.max(18, style.edgeLabelFontSize + ARROW_HEAD.width + maxStrokeWidth * 2)
1473
- }
1474
- function maxEdgeStyleWitnessGap(): number {
1475
- return FLOWCHART_DOTTED_DASH.dash * 3 + FLOWCHART_DOTTED_DASH.gap * 2
1476
- }
1477
- function terminalReadableGap(style: LabelMetricsStyle): number {
1478
- return maxTerminalMarkerGap(style) + maxEdgeStyleWitnessGap()
1479
- }
1480
- function labelHalfExtentAlongSegment(
1481
- box: { width: number; height: number },
1482
- a: Point,
1483
- b: Point,
1484
- ): number {
1485
- return Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) ? box.width / 2 : box.height / 2
1486
- }
1487
- function clearsTerminalMarkers(
1488
- edge: PositionedEdge,
1489
- points: Point[],
1490
- segmentIndex: number,
1491
- point: Point,
1492
- box: { width: number; height: number },
1493
- style: LabelMetricsStyle,
1494
- ): boolean {
1495
- const clearance = terminalMarkerGap(style, edge)
1496
- if (edge.hasArrowStart && segmentIndex === 1) {
1497
- const start = points[0]!
1498
- const halfExtent = labelHalfExtentAlongSegment(box, start, points[1]!)
1499
- if (Math.hypot(point.x - start.x, point.y - start.y) < halfExtent + clearance) return false
1500
- }
1501
- if (edge.hasArrowEnd && segmentIndex === points.length - 1) {
1502
- const end = points[points.length - 1]!
1503
- const halfExtent = labelHalfExtentAlongSegment(box, points[points.length - 2]!, end)
1504
- if (Math.hypot(point.x - end.x, point.y - end.y) < halfExtent + clearance) return false
1505
- }
1506
- return true
1507
- }
1508
- function labelEndpointGaps(edge: PositionedEdge, points: Point[], segmentIndex: number, style: LabelMetricsStyle): { start: number; end: number } {
1509
- return {
1510
- start: edge.hasArrowStart && segmentIndex === 1 ? maxTerminalMarkerGap(style) : labelPortStubGap(style),
1511
- end: edge.hasArrowEnd && segmentIndex === points.length - 1 ? terminalReadableGap(style) : labelPortStubGap(style),
1512
- }
1513
- }
1514
- function segmentAlignedWithFlow(a: Point, b: Point, direction: Direction): boolean {
1515
- return layoutFlow(direction).isHorizontal
1516
- ? Math.abs(a.y - b.y) < 0.01
1517
- : Math.abs(a.x - b.x) < 0.01
1518
- }
1519
- function labelFitsInsideSegment(
1520
- box: { width: number; height: number },
1521
- point: Point,
1522
- a: Point,
1523
- b: Point,
1524
- gaps: { start: number; end: number },
1525
- ): boolean {
1526
- const halfExtent = labelHalfExtentAlongSegment(box, a, b)
1527
- return Math.hypot(point.x - a.x, point.y - a.y) >= halfExtent + gaps.start &&
1528
- Math.hypot(point.x - b.x, point.y - b.y) >= halfExtent + gaps.end
1529
- }
1530
- function feasibleLabelSlots(box: { width: number; height: number }, a: Point, b: Point, gaps: { start: number; end: number }): number[] {
1531
- const length = Math.hypot(b.x - a.x, b.y - a.y)
1532
- if (length === 0) return []
1533
- const halfExtent = labelHalfExtentAlongSegment(box, a, b)
1534
- const start = (halfExtent + gaps.start) / length
1535
- const end = 1 - (halfExtent + gaps.end) / length
1536
- if (start > end) return []
1537
- return [(start + end) / 2]
1538
- }
1539
- function terminalLabelRunLength(
1540
- edge: PositionedEdge,
1541
- direction: Direction,
1542
- style: LabelMetricsStyle,
1543
- sourceBoundary: number,
1544
- targetBoundary: number,
1545
- ): number {
1546
- if (!edge.label) return 0
1547
- const f = layoutFlow(direction)
1548
- const box = labelBoxAt(edge.label, { x: 0, y: 0 }, style)
1549
- const labelExtent = f.isHorizontal ? box.width : box.height
1550
- const visibleGap = Math.max(
1551
- labelPortStubGap(style),
1552
- edge.hasArrowEnd ? terminalReadableGap(style) : labelPortStubGap(style),
1553
- )
1554
- // When there is room, balance the outside-source corridor into three equal
1555
- // visible runs: source boundary to bend, bend to label, and label to target.
1556
- const corridor = (targetBoundary - sourceBoundary) * f.sign
1557
- const balancedGap = (corridor - labelExtent) / 3
1558
- if (balancedGap >= visibleGap - 0.001) return labelExtent + balancedGap * 2
1559
- return labelExtent + labelPortStubGap(style) + visibleGap + 2
1560
- }
1561
- function terminalLabelCorridorLength(edge: PositionedEdge, direction: Direction, style: LabelMetricsStyle): number {
1562
- if (!edge.label) return 0
1563
- const box = labelBoxAt(edge.label, { x: 0, y: 0 }, style)
1564
- const labelExtent = layoutFlow(direction).isHorizontal ? box.width : box.height
1565
- const visibleGap = Math.max(
1566
- labelPortStubGap(style),
1567
- edge.hasArrowEnd ? terminalReadableGap(style) : labelPortStubGap(style),
1568
- )
1569
- return labelExtent + visibleGap * 3
1570
- }
1571
- function bundledLabelCandidates(edge: PositionedEdge, points: Point[], nodes: PositionedNode[], direction: Direction, style: LabelMetricsStyle): BundledLabelCandidate[] {
1572
- if (!edge.label) return []
1573
- const totalLength = polylineLength(points)
1574
- if (totalLength === 0) return []
1575
- const midpointDistance = totalLength / 2
1576
- const raw: Array<BundledLabelCandidate & { order: number }> = []
1577
- const slots = [0.5, 0.45, 0.55, 0.4, 0.6, 0.35, 0.65, 0.3, 0.7, 0.25, 0.75, 0.2, 0.8, 1 / 6, 5 / 6]
1578
-
1579
- let segmentStartDistance = 0
1580
- for (let i = 1; i < points.length; i++) {
1581
- const a = points[i - 1]!, b = points[i]!
1582
- const length = Math.hypot(b.x - a.x, b.y - a.y)
1583
- if (length === 0) continue
1584
- const flowAligned = segmentAlignedWithFlow(a, b, direction)
1585
- const terminalSegment = (edge.hasArrowStart && i === 1) || (edge.hasArrowEnd && i === points.length - 1)
1586
- const baseBox = labelBoxAt(edge.label, { x: 0, y: 0 }, style)
1587
- const gaps = labelEndpointGaps(edge, points, i, style)
1588
- const segmentSlots = [...slots, ...feasibleLabelSlots(baseBox, a, b, gaps)]
1589
- .filter((slot, index, all) => slot >= 0 && slot <= 1 && all.findIndex(other => Math.abs(other - slot) < 0.001) === index)
1590
- for (let order = 0; order < segmentSlots.length; order++) {
1591
- const t = segmentSlots[order]!
1592
- const point = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }
1593
- const box = labelBoxAt(edge.label, point, style)
1594
- if (nodes.some(node => rectsOverlap(box, node, 2))) continue
1595
- if (!clearsTerminalMarkers(edge, points, i, point, box, style)) continue
1596
- const fitsInsideSegment = labelFitsInsideSegment(box, point, a, b, gaps)
1597
- raw.push({
1598
- point,
1599
- box,
1600
- order,
1601
- rank: 0,
1602
- pathDistance: segmentStartDistance + length * t,
1603
- routeMidpointDistance: Math.abs(segmentStartDistance + length * t - midpointDistance),
1604
- segmentMidpointDistance: Math.abs(length * (t - 0.5)),
1605
- straightRunPenalty: flowAligned && fitsInsideSegment ? 0 : flowAligned ? 2 : 10,
1606
- terminalSegmentPenalty: terminalSegment ? 1 : 0,
1607
- })
1608
- }
1609
- segmentStartDistance += length
1610
- }
1611
-
1612
- raw.sort((a, b) =>
1613
- a.straightRunPenalty - b.straightRunPenalty ||
1614
- a.terminalSegmentPenalty - b.terminalSegmentPenalty ||
1615
- a.segmentMidpointDistance - b.segmentMidpointDistance ||
1616
- a.routeMidpointDistance - b.routeMidpointDistance ||
1617
- a.order - b.order ||
1618
- a.pathDistance - b.pathDistance)
1619
- return raw.map((candidate, rank) => ({ ...candidate, rank }))
1620
- }
1621
- function bestBundledLabelPosition(edge: PositionedEdge, points: Point[], nodes: PositionedNode[], direction: Direction, style: LabelMetricsStyle): Point {
1622
- return bundledLabelCandidates(edge, points, nodes, direction, style)[0]?.point ?? calculatePathMidpoint(points)
1623
- }
1624
- function bundledLabelAssignmentCost(chosen: BundledLabelCandidate[], direction: Direction): number {
1625
- const f = layoutFlow(direction)
1626
- const mainValues = chosen.map(candidate => candidate.point[f.main])
1627
- const mainSpread = Math.max(...mainValues) - Math.min(...mainValues)
1628
- const totalStraightRunPenalty = chosen.reduce((sum, candidate) => sum + candidate.straightRunPenalty, 0)
1629
- const totalTerminalSegmentPenalty = chosen.reduce((sum, candidate) => sum + candidate.terminalSegmentPenalty, 0)
1630
- const totalSegmentMidpointDistance = chosen.reduce((sum, candidate) => sum + candidate.segmentMidpointDistance, 0)
1631
- const totalRouteMidpointDistance = chosen.reduce((sum, candidate) => sum + candidate.routeMidpointDistance, 0)
1632
- const totalRank = chosen.reduce((sum, candidate) => sum + candidate.rank, 0)
1633
- // Prefer labels on a flow-axis straight run so the route enters and exits through
1634
- // opposite label ports. Among readable straight-run slots, preserve sibling
1635
- // symmetry before endpoint aesthetics; a terminal segment is acceptable once
1636
- // marker clearance has already admitted the candidate.
1637
- return totalStraightRunPenalty * 10000 +
1638
- mainSpread * 100 +
1639
- totalTerminalSegmentPenalty * 1000 +
1640
- totalSegmentMidpointDistance * 4 +
1641
- totalRank * 0.25 +
1642
- totalRouteMidpointDistance * 0.1
1643
- }
1644
- /** Arc-length position (0..1) of the closest point on `points` to `pt`. The
1645
- * centring-quality measure: 0.5 is the route's arc-length midpoint. Mirrors the
1646
- * rubric's projFrac (layout-rubric.ts) so the producer and the oracle agree. */
1647
- function labelArcFraction(pt: Point, points: Point[]): number {
1648
- let total = 0
1649
- const seglen: number[] = []
1650
- for (let i = 1; i < points.length; i++) {
1651
- const d = Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
1652
- seglen.push(d); total += d
1653
- }
1654
- let best = Infinity, bestArc = 0, acc = 0
1655
- for (let i = 1; i < points.length; i++) {
1656
- const a = points[i - 1]!, b = points[i]!, dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy
1657
- let t = l2 ? ((pt.x - a.x) * dx + (pt.y - a.y) * dy) / l2 : 0
1658
- t = Math.max(0, Math.min(1, t))
1659
- const px = a.x + t * dx, py = a.y + t * dy, d = Math.hypot(pt.x - px, pt.y - py)
1660
- if (d < best) { best = d; bestArc = acc + t * seglen[i - 1]! }
1661
- acc += seglen[i - 1]!
1662
- }
1663
- return total ? bestArc / total : 0.5
1664
- }
1665
- /**
1666
- * The point on a (rectilinear) dogleg at the route's MAIN-AXIS midpoint — dagre's
1667
- * centred edge-label placement. Walks segments for the one straddling the
1668
- * main-axis midpoint, preferring a flow-aligned segment so the label sits on a
1669
- * straight run / across the converging elbow rather than along the cross jog.
1670
- *
1671
- * The midpoint of a symmetric converging dogleg sits AT (or beside) the elbow — an
1672
- * INTERIOR bend, not a node-adjacent endpoint — so no port-stub reservation
1673
- * applies there; the caller's node-overlap and terminal-marker guards are the only
1674
- * clearances that matter, and they are checked in recenterBundledLabelOnMainAxis.
1675
- * Null if no flow-aligned segment spans the midpoint (degenerate route).
1676
- * Pure/deterministic.
1677
- */
1678
- function mainAxisMidpointOnRoute(points: Point[], direction: Direction): Point | null {
1679
- const f = layoutFlow(direction)
1680
- const mains = points.map(p => p[f.main])
1681
- const mid = (Math.min(...mains) + Math.max(...mains)) / 2
1682
- let fallback: Point | null = null
1683
- for (let i = 1; i < points.length; i++) {
1684
- const a = points[i - 1]!, b = points[i]!
1685
- const lo = Math.min(a[f.main], b[f.main]), hi = Math.max(a[f.main], b[f.main])
1686
- if (mid < lo - 1e-6 || mid > hi + 1e-6) continue
1687
- const span = b[f.main] - a[f.main]
1688
- if (Math.abs(span) <= 1e-6) continue // cross-aligned segment: not a straight run
1689
- const t = (mid - a[f.main]) / span
1690
- const point = { [f.main]: mid, [f.cross]: a[f.cross] + t * (b[f.cross] - a[f.cross]) } as unknown as Point
1691
- // Prefer a flow-aligned segment (source/target cross coords equal).
1692
- if (Math.abs(a[f.cross] - b[f.cross]) < 0.01) return point
1693
- fallback ??= point
1694
- }
1695
- return fallback
1696
- }
1697
- /**
1698
- * Re-centre a bundled symmetric-dogleg label onto the route's main-axis midpoint
1699
- * (dagre's centred placement) when that strictly improves centring and stays
1700
- * clean. The candidate placer ranks by per-SEGMENT midpoint, so on a converging
1701
- * dogleg it parks the label at the middle of the longest flow-aligned segment —
1702
- * which is near an endpoint of the whole ROUTE (the symmetric-fan-in/fan-out
1703
- * hugging). This re-homes it to the route's main-axis centre.
1704
- *
1705
- * Label-only, post-route, HARD-safe: the re-centred point lies ON the route by
1706
- * construction (it is a point on a segment), so labelOffRoute stays 0; and we
1707
- * only move the label if the box there clears every node (same 2px margin the
1708
- * candidate generator uses), clears the terminal arrow markers (same check the
1709
- * candidate generator uses), clears the sibling labels, AND is more central than
1710
- * the old spot. If any guard fails the original placement stands — we never push
1711
- * a label off-route, into a collision, or onto an arrow head, and never disturb
1712
- * an already-centred label.
1713
- */
1714
- function recenterBundledLabelOnMainAxis(
1715
- edge: PositionedEdge,
1716
- points: Point[],
1717
- nodes: PositionedNode[],
1718
- direction: Direction,
1719
- style: LabelMetricsStyle,
1720
- siblingBoxes: ReadonlyArray<{ x: number; y: number; width: number; height: number }> = [],
1721
- siblingGap = 0,
1722
- ): void {
1723
- if (!edge.label || !edge.labelPosition) return
1724
- const target = mainAxisMidpointOnRoute(points, direction)
1725
- if (!target) return
1726
- // Only re-centre if it is actually more central than the current placement.
1727
- const currentOffset = Math.abs(labelArcFraction(edge.labelPosition, points) - 0.5)
1728
- const targetOffset = Math.abs(labelArcFraction(target, points) - 0.5)
1729
- if (targetOffset >= currentOffset - 1e-6) return
1730
- const box = labelBoxAt(edge.label, target, style)
1731
- if (nodes.some(node => rectsOverlap(box, node, 2))) return
1732
- if (siblingBoxes.some(other => rectsOverlap(box, other, siblingGap))) return
1733
- // Terminal-marker clearance on the segment the re-centred point lands on (the
1734
- // same guard the candidate generator applies), so re-centring never parks the
1735
- // label over a start/end arrow head.
1736
- const f = layoutFlow(direction)
1737
- for (let i = 1; i < points.length; i++) {
1738
- const a = points[i - 1]!, b = points[i]!
1739
- const lo = Math.min(a[f.main], b[f.main]), hi = Math.max(a[f.main], b[f.main])
1740
- if (Math.abs(b[f.main] - a[f.main]) <= 1e-6) continue
1741
- if (target[f.main] < lo - 1e-6 || target[f.main] > hi + 1e-6) continue
1742
- if (!clearsTerminalMarkers(edge, points, i, target, box, style)) return
1743
- break
1744
- }
1745
- edge.labelPosition = target
1746
- }
1747
- function assignBundledFanoutLabels(
1748
- proposed: Array<{ edge: PositionedEdge; points: Point[] }>,
1749
- nodes: PositionedNode[],
1750
- direction: Direction,
1751
- style: LabelMetricsStyle,
1752
- // Re-centre each placed label onto its route's main-axis midpoint afterward.
1753
- // Opt-IN, used only by the co-rank fan-in (markCorankFanInBundles): its
1754
- // converging dogleg parks the label at a segment midpoint that hugs the route
1755
- // end. The fan-out emitter does NOT opt in — its terminal-segment labels are
1756
- // deliberately corridor-balanced (terminalLabelRunLength / issue #38), a
1757
- // placement re-centring would disturb; leaving that path untouched keeps the
1758
- // change scoped to the new regression.
1759
- recenter = false,
1760
- ): void {
1761
- const labeled = proposed.filter(({ edge }) => edge.label)
1762
- if (labeled.length === 0) return
1763
- if (labeled.length === 1) {
1764
- const only = labeled[0]!
1765
- only.edge.labelPosition = bestBundledLabelPosition(only.edge, only.points, nodes, direction, style)
1766
- } else {
1767
- const candidateSets = labeled.map(({ edge, points }) => bundledLabelCandidates(edge, points, nodes, direction, style))
1768
- if (candidateSets.some(candidates => candidates.length === 0)) {
1769
- for (const { edge, points } of labeled) edge.labelPosition = bestBundledLabelPosition(edge, points, nodes, direction, style)
1770
- } else {
1771
- const gap = readableLabelGap(style)
1772
- let best: BundledLabelCandidate[] | undefined
1773
- let bestCost = Number.POSITIVE_INFINITY
1774
- const chosen: BundledLabelCandidate[] = []
1775
- const search = (index: number) => {
1776
- if (index === candidateSets.length) {
1777
- const cost = bundledLabelAssignmentCost(chosen, direction)
1778
- if (cost < bestCost) {
1779
- bestCost = cost
1780
- best = chosen.slice()
1781
- }
1782
- return
1783
- }
1784
- for (const candidate of candidateSets[index]!) {
1785
- if (chosen.some(other => rectsOverlap(candidate.box, other.box, gap))) continue
1786
- chosen.push(candidate)
1787
- search(index + 1)
1788
- chosen.pop()
1789
- }
1790
- }
1791
- search(0)
1792
-
1793
- if (!best) {
1794
- for (const { edge, points } of labeled) edge.labelPosition = bestBundledLabelPosition(edge, points, nodes, direction, style)
1795
- } else {
1796
- for (let i = 0; i < labeled.length; i++) labeled[i]!.edge.labelPosition = best[i]!.point
1797
- }
1798
- }
1799
- }
1800
-
1801
- // Opt-in re-centre: snap each placed label to its route's main-axis midpoint
1802
- // (dagre's centred placement) when that is strictly more central and stays
1803
- // clean. The per-segment candidate ranking above parks a label at the middle of
1804
- // the longest flow-aligned segment, which on the co-rank fan-in's converging
1805
- // dogleg hugs the route end. Label-only, post-route, HARD-safe; a re-centre that
1806
- // would collide with a node OR a sibling label already re-centred here is
1807
- // declined, so sibling separation is preserved.
1808
- if (!recenter) return
1809
- const siblingBoxes: Array<{ x: number; y: number; width: number; height: number }> = []
1810
- const gap = readableLabelGap(style)
1811
- for (const { edge } of labeled) {
1812
- if (edge.labelPosition) siblingBoxes.push(labelBoxAt(edge.label!, edge.labelPosition, style))
1813
- }
1814
- for (let i = 0; i < labeled.length; i++) {
1815
- const { edge, points } = labeled[i]!
1816
- const before = edge.labelPosition
1817
- const others = siblingBoxes.filter((_, j) => j !== i)
1818
- recenterBundledLabelOnMainAxis(edge, points, nodes, direction, style, others, gap)
1819
- // Keep the sibling-box list current so a later edge sees this one's new spot.
1820
- if (edge.labelPosition && edge.labelPosition !== before) siblingBoxes[i] = labelBoxAt(edge.label!, edge.labelPosition, style)
1821
- }
1822
- }
1823
- export function reassignBundledSiblingLabels(
1824
- nodes: PositionedNode[],
1825
- edges: PositionedEdge[],
1826
- bundled: ReadonlySet<PositionedEdge>,
1827
- direction: Direction,
1828
- style: LabelMetricsStyle,
1829
- ): void {
1830
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
1831
- const bySource = new Map<string, PositionedEdge[]>()
1832
- for (const edge of edges) {
1833
- if (!bundled.has(edge) || !edge.label || !positionedEdgeForwardish(edge, nodeMap, direction)) continue
1834
- if (!bySource.has(edge.source)) bySource.set(edge.source, [])
1835
- bySource.get(edge.source)!.push(edge)
1836
- }
1837
- for (const group of bySource.values()) {
1838
- if (group.length < 2 || group.length > 3) continue
1839
- if (new Set(group.map(edge => edge.target)).size < group.length) continue
1840
- const sorted = [...group].sort((a, b) => {
1841
- const aTarget = nodeMap.get(a.target)
1842
- const bTarget = nodeMap.get(b.target)
1843
- if (!aTarget || !bTarget) return (a.edgeIndex ?? 0) - (b.edgeIndex ?? 0)
1844
- return nodeCrossCenter(aTarget, direction) - nodeCrossCenter(bTarget, direction)
1845
- })
1846
- assignBundledFanoutLabels(sorted.map(edge => ({ edge, points: edge.points })), nodes, direction, style)
1847
- }
1848
- }
1849
- export function applySymmetricFanoutEmissions(
1850
- nodes: PositionedNode[],
1851
- edges: PositionedEdge[],
1852
- groups: PositionedGroup[],
1853
- graph: MermaidGraph,
1854
- bundled: Set<PositionedEdge>,
1855
- style: LabelMetricsStyle,
1856
- ): void {
1857
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
1858
- const bySource = new Map<string, PositionedEdge[]>()
1859
- for (const edge of edges) {
1860
- if (!positionedEdgeForwardish(edge, nodeMap, graph.direction)) continue
1861
- if (!bySource.has(edge.source)) bySource.set(edge.source, [])
1862
- bySource.get(edge.source)!.push(edge)
1863
- }
1864
- const f = layoutFlow(graph.direction)
1865
- for (const [sourceId, raw] of bySource) {
1866
- if (raw.length < 2 || raw.length > 3) continue
1867
- const source = nodeMap.get(sourceId)
1868
- if (!source || (source.shape !== 'rectangle' && source.shape !== 'diamond')) continue
1869
- const sourceInGroup = nodeInsideGroups(source, groups)
1870
- if (source.shape !== 'diamond' && sourceInGroup) continue
1871
- const sorted = [...raw].sort((a, b) => nodeCrossCenter(nodeMap.get(a.target)!, graph.direction) - nodeCrossCenter(nodeMap.get(b.target)!, graph.direction))
1872
- const targets = sorted.map(e => nodeMap.get(e.target)!).filter(Boolean)
1873
- if (targets.length !== sorted.length) continue
1874
- if (!targets.every(t => t.shape === 'rectangle' && (source.shape === 'diamond' || !nodeInsideGroups(t, groups)))) continue
1875
- // Same-rank gate. Fan-out targets drift apart on the main axis when their
1876
- // label widths differ ("warnings" 97px vs "ok" 60px → ~32px apart), so the
1877
- // tolerance must clear that width-driven drift to see them as peers — while
1878
- // staying below layerSpacing (48) so genuinely different ranks are still
1879
- // rejected. The peer confirmation just below is the real safety net, so
1880
- // loosening this gate cannot equalize non-peers. (Equalizing such peers is
1881
- // exactly the symmetry the diamond/"warnings"/"ok" fan-out wants; verified
1882
- // byte-identical on the corpus, which has no fan-out in the 28–40px band.)
1883
- if (!sameFlowLayer(targets, graph.direction, 40)) continue
1884
- if (!targets.every(t => edges.every(e => e.source !== t.id && (e.target !== t.id || e.source === source.id)))) continue
1885
- let peer = true
1886
- for (let i = 0; i < targets.length; i++) for (let j = 0; j < targets.length; j++) {
1887
- if (i !== j && logicalGraphReaches(graph, targets[i]!.id, targets[j]!.id)) peer = false
1888
- }
1889
- if (!peer) continue
1890
-
1891
- const oldTargets = targets.map(t => ({ t, x: t.x, y: t.y, width: t.width, height: t.height }))
1892
- const oldGroups = flattenPositionedGroups(groups).map(group => ({ group, x: group.x, y: group.y, width: group.width, height: group.height }))
1893
- const restoreGeometry = () => {
1894
- for (const old of oldTargets) { old.t.x = old.x; old.t.y = old.y; old.t.width = old.width; old.t.height = old.height }
1895
- for (const old of oldGroups) { old.group.x = old.x; old.group.y = old.y; old.group.width = old.width; old.group.height = old.height }
1896
- }
1897
- const maxWidth = Math.max(...targets.map(t => t.width))
1898
- const maxHeight = Math.max(...targets.map(t => t.height))
1899
- const axis = nodeCrossCenter(source, graph.direction)
1900
- const rank = Math.min(...targets.map(t => nodeMainStart(t, graph.direction)))
1901
- const spacing = (f.isHorizontal ? maxHeight : maxWidth) + 28
1902
- const offsets = symmetricOffsets(targets.length, spacing)
1903
- const sourceAlignedCross = source.shape === 'diamond'
1904
- ? sorted.map((_, i) => sourceEmissionPort(source, graph.direction, i, sorted.length)[f.cross])
1905
- : undefined
1906
- const placeTargets = (crossCenters: number[]) => {
1907
- for (let i = 0; i < targets.length; i++) {
1908
- const target = targets[i]!
1909
- target.width = maxWidth
1910
- target.height = maxHeight
1911
- if (f.isHorizontal) {
1912
- target.x = rank
1913
- target.y = crossCenters[i]! - maxHeight / 2
1914
- } else {
1915
- target.x = crossCenters[i]! - maxWidth / 2
1916
- target.y = rank
1917
- }
1918
- }
1919
- }
1920
- placeTargets(sourceAlignedCross ?? offsets.map(offset => axis + offset))
1921
- const targetIds = new Set(targets.map(t => t.id))
1922
- const placementClear = (): boolean => {
1923
- for (let i = 0; i < targets.length; i++) {
1924
- const target = targets[i]!
1925
- if (target.x < 0 || target.y < 0) return false
1926
- for (let j = i + 1; j < targets.length; j++) {
1927
- if (rectsOverlap(target, targets[j]!, 8)) return false
1928
- }
1929
- }
1930
- for (const target of targets) {
1931
- for (const other of nodes) {
1932
- if (targetIds.has(other.id)) continue
1933
- if (rectsOverlap(target, other, 8)) return false
1934
- }
1935
- }
1936
- return true
1937
- }
1938
- let clear = placementClear()
1939
- if (!clear && sourceAlignedCross) {
1940
- placeTargets(offsets.map(offset => axis + offset))
1941
- clear = placementClear()
1942
- }
1943
- if (!clear) {
1944
- restoreGeometry()
1945
- continue
1946
- }
1947
-
1948
- const sourceEdge = f.isHorizontal
1949
- ? (f.sourceSide === 'E' ? source.x + source.width : source.x)
1950
- : (f.sourceSide === 'S' ? source.y + source.height : source.y)
1951
- let targetEdge = f.isHorizontal
1952
- ? (f.sign > 0 ? Math.min(...targets.map(t => t.x)) : Math.max(...targets.map(t => t.x + t.width)))
1953
- : (f.sign > 0 ? Math.min(...targets.map(t => t.y)) : Math.max(...targets.map(t => t.y + t.height)))
1954
- const requiredCorridor = Math.max(0, ...sorted.map(edge => terminalLabelCorridorLength(edge, graph.direction, style)))
1955
- const currentCorridor = (targetEdge - sourceEdge) * f.sign
1956
- if (requiredCorridor > currentCorridor + 0.001) {
1957
- const delta = requiredCorridor - currentCorridor
1958
- const targetRectsBeforeShift = targets.map(t => ({ x: t.x, y: t.y, width: t.width, height: t.height }))
1959
- for (const target of targets) {
1960
- if (f.isHorizontal) target.x += delta * f.sign
1961
- else target.y += delta * f.sign
1962
- }
1963
- expandGroupsForMainShift(groups, targetRectsBeforeShift, graph.direction, delta)
1964
- clear = placementClear()
1965
- if (!clear) {
1966
- restoreGeometry()
1967
- continue
1968
- }
1969
- targetEdge = f.isHorizontal
1970
- ? (f.sign > 0 ? Math.min(...targets.map(t => t.x)) : Math.max(...targets.map(t => t.x + t.width)))
1971
- : (f.sign > 0 ? Math.min(...targets.map(t => t.y)) : Math.max(...targets.map(t => t.y + t.height)))
1972
- }
1973
- const requiredTerminalRun = Math.max(0, ...sorted.map(edge => terminalLabelRunLength(edge, graph.direction, style, sourceEdge, targetEdge)))
1974
- const midpointElbow = sourceEdge + (targetEdge - sourceEdge) * 0.5
1975
- const capacityElbow = targetEdge - f.sign * requiredTerminalRun
1976
- const elbow = ((capacityElbow - midpointElbow) * f.sign < 0) ? capacityElbow : midpointElbow
1977
- const proposed: Array<{ edge: PositionedEdge; points: Point[] }> = []
1978
- for (let i = 0; i < sorted.length; i++) {
1979
- const edge = sorted[i]!
1980
- const target = nodeMap.get(edge.target)!
1981
- const points = doglegBetween(sourceEmissionPort(source, graph.direction, i, sorted.length), targetFlowPort(target, graph.direction), graph.direction, elbow)
1982
- if (!routeClearOfNodes(points, nodes, new Set([edge.source, edge.target]))) { clear = false; break }
1983
- proposed.push({ edge, points })
1984
- }
1985
- if (!clear) {
1986
- restoreGeometry()
1987
- continue
1988
- }
1989
- for (const { edge, points } of proposed) {
1990
- edge.points = points
1991
- edge.routeCertificate = undefined
1992
- bundled.add(edge)
1993
- }
1994
- assignBundledFanoutLabels(proposed, nodes, graph.direction, style)
1995
- }
1996
- }
1997
- function segmentIntersectsLabelBox(a: Point, b: Point, box: { x: number; y: number; w: number; h: number }, pad = 2): boolean {
1998
- const x1 = box.x - pad, x2 = box.x + box.w + pad, y1 = box.y - pad, y2 = box.y + box.h + pad
1999
- if (Math.abs(a.x - b.x) < 0.01) {
2000
- const lo = Math.min(a.y, b.y), hi = Math.max(a.y, b.y)
2001
- return a.x >= x1 && a.x <= x2 && hi >= y1 && lo <= y2
2002
- }
2003
- if (Math.abs(a.y - b.y) < 0.01) {
2004
- const lo = Math.min(a.x, b.x), hi = Math.max(a.x, b.x)
2005
- return a.y >= y1 && a.y <= y2 && hi >= x1 && lo <= x2
2006
- }
2007
- return false
2008
- }
2009
- function labelTouchesNeighborRoute(edge: PositionedEdge, labelBox: { x: number; y: number; w: number; h: number }, edges: PositionedEdge[]): boolean {
2010
- for (const other of edges) {
2011
- if (other === edge) continue
2012
- for (let i = 1; i < other.points.length; i++) {
2013
- if (segmentIntersectsLabelBox(other.points[i - 1]!, other.points[i]!, labelBox)) return true
2014
- }
2015
- }
2016
- return false
2017
- }
2018
- function symmetricParallelOuterLanePoints(source: PositionedNode, target: PositionedNode, direction: Direction, lane: number, index: number, count: number): Point[] {
2019
- const f = layoutFlow(direction)
2020
- const t = (index + 1) / (count + 1)
2021
- if (f.isHorizontal) {
2022
- const p: Point = { x: f.sourceSide === 'E' ? source.x + source.width : source.x, y: source.y + source.height * t }
2023
- const q: Point = { x: f.targetSide === 'W' ? target.x : target.x + target.width, y: target.y + target.height * t }
2024
- const gap = Math.min(28, Math.max(14, Math.abs(q.x - p.x) * 0.18))
2025
- const x1 = p.x + f.sign * gap
2026
- const x2 = q.x - f.sign * gap
2027
- return simplifyPolyline([p, { x: x1, y: p.y }, { x: x1, y: lane }, { x: x2, y: lane }, { x: x2, y: q.y }, q])
2028
- }
2029
- const p: Point = { x: source.x + source.width * t, y: f.sourceSide === 'S' ? source.y + source.height : source.y }
2030
- const q: Point = { x: target.x + target.width * t, y: f.targetSide === 'N' ? target.y : target.y + target.height }
2031
- const gap = Math.min(28, Math.max(14, Math.abs(q.y - p.y) * 0.18))
2032
- const y1 = p.y + f.sign * gap
2033
- const y2 = q.y - f.sign * gap
2034
- return simplifyPolyline([p, { x: p.x, y: y1 }, { x: lane, y: y1 }, { x: lane, y: y2 }, { x: q.x, y: y2 }, q])
2035
- }
2036
- export function applySymmetricParallelEdgeLanes(
2037
- nodes: PositionedNode[],
2038
- edges: PositionedEdge[],
2039
- groups: PositionedGroup[],
2040
- graph: MermaidGraph,
2041
- bundled: Set<PositionedEdge>,
2042
- style: LabelMetricsStyle,
2043
- ): void {
2044
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
2045
- const f = layoutFlow(graph.direction)
2046
- const byPair = new Map<string, PositionedEdge[]>()
2047
- for (const edge of edges) {
2048
- if (edge.source === edge.target || !edge.label) continue
2049
- const source = nodeMap.get(edge.source)
2050
- const target = nodeMap.get(edge.target)
2051
- if (!source || !target || source.shape !== 'rectangle' || target.shape !== 'rectangle') continue
2052
- if (nodeInsideGroups(source, groups) || nodeInsideGroups(target, groups)) continue
2053
- const key = `${edge.source}\0${edge.target}`
2054
- if (!byPair.has(key)) byPair.set(key, [])
2055
- byPair.get(key)!.push(edge)
2056
- }
2057
- for (const group of byPair.values()) {
2058
- group.sort((a, b) => (a.edgeIndex ?? 0) - (b.edgeIndex ?? 0))
2059
- if (group.length !== 2) continue
2060
- const source = nodeMap.get(group[0]!.source)!
2061
- const target = nodeMap.get(group[0]!.target)!
2062
- const labelWidths = group.map(e => measureMultilineText(e.label!, style.edgeLabelFontSize, style.edgeLabelFontWeight).width + 16)
2063
- const labelHeights = group.map(e => measureMultilineText(e.label!, style.edgeLabelFontSize, style.edgeLabelFontWeight).height + 16)
2064
- const lanePadding = (f.isHorizontal ? Math.max(...labelHeights) : Math.max(...labelWidths)) / 2 + 18
2065
- const attempts = [lanePadding, lanePadding + 16, lanePadding + 32, lanePadding + 52]
2066
- for (const gap of attempts) {
2067
- const lanes = f.isHorizontal
2068
- ? [Math.min(source.y, target.y) - gap, Math.max(source.y + source.height, target.y + target.height) + gap]
2069
- : [Math.min(source.x, target.x) - gap, Math.max(source.x + source.width, target.x + target.width) + gap]
2070
- const proposed: Array<{ edge: PositionedEdge; points: Point[]; label: Point }> = []
2071
- let ok = true
2072
- for (let i = 0; i < group.length; i++) {
2073
- const edge = group[i]!
2074
- const points = symmetricParallelOuterLanePoints(source, target, graph.direction, lanes[i]!, i, group.length)
2075
- if (!routeClearOfNodes(points, nodes, new Set([source.id, target.id]))) { ok = false; break }
2076
- proposed.push({ edge, points, label: longestSegmentMidpoint(points) })
2077
- }
2078
- if (!ok) continue
2079
- for (const p of proposed) {
2080
- const metrics = measureMultilineText(p.edge.label!, style.edgeLabelFontSize, style.edgeLabelFontWeight)
2081
- const box = { x: p.label.x - (metrics.width + 16) / 2, y: p.label.y - (metrics.height + 16) / 2, w: metrics.width + 16, h: metrics.height + 16 }
2082
- if (labelTouchesNeighborRoute(p.edge, box, edges)) ok = false
2083
- }
2084
- if (!ok) continue
2085
- for (const { edge, points, label } of proposed) {
2086
- edge.points = points
2087
- edge.labelPosition = label
2088
- edge.routeCertificate = undefined
2089
- bundled.add(edge)
2090
- }
2091
- break
2092
- }
2093
- }
2094
- }
2095
-
2096
- /**
2097
- * Parallel-lane contract for duplicate same-direction edges (a multigraph:
2098
- * `A --> B` written two or more times). ELK + the port allocator fan the
2099
- * endpoints across a node side, but only a few pixels apart, and the
2100
- * per-edge bends stack at the same column — a cramped near-overlapping
2101
- * double line. The graph-drawing convention (yFiles ParallelEdgeRouter; OGDF's
2102
- * Kandinsky "center straight, neighbours offset" model) is to route the group
2103
- * as EVENLY-SEPARATED parallel orthogonal lanes. We do exactly that here:
2104
- *
2105
- * - spread both endpoints across distinct, evenly-spaced slots on the shared
2106
- * flow sides (separation clamped to fit the shorter side, so endpoints stay
2107
- * on the outline — verified with the same `onShapeOutline` oracle the tests
2108
- * use), and
2109
- * - when the nodes are offset on the cross axis, give each edge its own jog
2110
- * column (staggered by the lane separation) so the vertical segments run
2111
- * parallel instead of overlapping.
2112
- *
2113
- * Scoped to the rare duplicate-pair case: only UNLABELED, forward, side-to-side
2114
- * pairs between PORT_EXACT shapes outside any group. Labeled parallel pairs are
2115
- * owned by `applySymmetricParallelEdgeLanes` (they need outer lanes for their
2116
- * labels); everything else is left to the certifying route contracts.
2117
- */
2118
- export function applyParallelDuplicateLanes(
2119
- nodes: PositionedNode[],
2120
- edges: PositionedEdge[],
2121
- groups: PositionedGroup[],
2122
- graph: MermaidGraph,
2123
- bundled: Set<PositionedEdge>,
2124
- ): void {
2125
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
2126
- const f = layoutFlow(graph.direction)
2127
- const TARGET_SEP = 14
2128
- const MARGIN = 5
2129
- const BASE_GAP = 16
2130
- const mainSize = (n: PositionedNode) => (f.isHorizontal ? n.width : n.height)
2131
- const crossSize = (n: PositionedNode) => (f.isHorizontal ? n.height : n.width)
2132
- const mk = (mainVal: number, crossVal: number): Point =>
2133
- f.isHorizontal ? { x: mainVal, y: crossVal } : { x: crossVal, y: mainVal }
2134
-
2135
- // Target-centric fan-in distribution. We bucket every side-to-side, unlabeled
2136
- // edge by the hub it enters — not just by directed pair — because a duplicate
2137
- // band is only readable if the OTHER edges sharing that hub's entry face are
2138
- // spread with it. Distributing the whole bucket fixes three defects the old
2139
- // pair-only pass left behind:
2140
- // - crowding/crossing: a third distinct feeder (e.g. N4->N3 in issue #62)
2141
- // used to land inside a separated duplicate band, crossing through it;
2142
- // - silent collapse: when a hub had other feeders the pair-only pass often
2143
- // bailed, leaving ELK's overlap so the duplicates rendered as one line;
2144
- // - tight feedback pairs: a duplicate BACK-edge entered the hub's flow-exit
2145
- // face at ~6px because neither lane pass owned it.
2146
- // Forward edges enter the hub's flow-facing side; feedback (back) edges enter
2147
- // the opposite, flow-exit side. We handle the two faces as independent buckets
2148
- // with mirrored exit/entry sides and jog direction. Gated on a genuine
2149
- // duplicate (same source >= 2) per face, so pure distinct-peer fan-in is left
2150
- // to the merge-centering pass (issue #26 WS3).
2151
- const highSide = (n: PositionedNode) => n[f.main] + mainSize(n)
2152
- const lowSide = (n: PositionedNode) => n[f.main]
2153
- const sourceOnHigh = f.sourceSide === 'E' || f.sourceSide === 'S'
2154
- const targetOnLow = f.targetSide === 'W' || f.targetSide === 'N'
2155
- // Forward uses the flow sides; feedback uses their opposites.
2156
- const exitSide = (n: PositionedNode, back: boolean) => (sourceOnHigh !== back ? highSide(n) : lowSide(n))
2157
- const entrySide = (n: PositionedNode, back: boolean) => (targetOnLow !== back ? lowSide(n) : highSide(n))
2158
- const preSeparated = new Set<PositionedEdge>()
2159
- const routesShareCollinearRun = (a: PositionedEdge, b: PositionedEdge): boolean => {
2160
- for (let i = 1; i < a.points.length; i++) {
2161
- const a0 = a.points[i - 1]!, a1 = a.points[i]!
2162
- const aVertical = Math.abs(a0.x - a1.x) < 0.5
2163
- const aHorizontal = Math.abs(a0.y - a1.y) < 0.5
2164
- if (!aVertical && !aHorizontal) continue
2165
- for (let j = 1; j < b.points.length; j++) {
2166
- const b0 = b.points[j - 1]!, b1 = b.points[j]!
2167
- if (aVertical) {
2168
- if (Math.abs(b0.x - b1.x) >= 0.5 || Math.abs(a0.x - b0.x) >= 1) continue
2169
- const overlap = Math.min(Math.max(a0.y, a1.y), Math.max(b0.y, b1.y)) -
2170
- Math.max(Math.min(a0.y, a1.y), Math.min(b0.y, b1.y))
2171
- if (overlap > 1) return true
2172
- } else if (aHorizontal) {
2173
- if (Math.abs(b0.y - b1.y) >= 0.5 || Math.abs(a0.y - b0.y) >= 1) continue
2174
- const overlap = Math.min(Math.max(a0.x, a1.x), Math.max(b0.x, b1.x)) -
2175
- Math.max(Math.min(a0.x, a1.x), Math.min(b0.x, b1.x))
2176
- if (overlap > 1) return true
2177
- }
2178
- }
2179
- }
2180
- return false
2181
- }
2182
-
2183
- // Mixed labeled+unlabeled duplicates used to fall through every pre-freeze
2184
- // lane splitter: the labeled member was not eligible for duplicate lanes,
2185
- // and the lone unlabeled member was not a duplicate by itself. The
2186
- // post-freeze shared-trunk label repair then had to reroute geometry. Own the
2187
- // exact-pair case here instead, before route certificates are issued.
2188
- const byDirectedPair = new Map<string, PositionedEdge[]>()
2189
- for (const edge of edges) {
2190
- if (edge.source === edge.target) continue
2191
- const source = nodeMap.get(edge.source)
2192
- const target = nodeMap.get(edge.target)
2193
- if (!source || !target) continue
2194
- if (!PORT_EXACT.has(source.shape) || !PORT_EXACT.has(target.shape)) continue
2195
- if (nodeInsideGroups(source, groups) || nodeInsideGroups(target, groups)) continue
2196
- const forwardGap = (entrySide(target, false) - exitSide(source, false)) * f.sign
2197
- const feedbackGap = (exitSide(source, true) - entrySide(target, true)) * f.sign
2198
- if (forwardGap <= BASE_GAP && feedbackGap <= BASE_GAP) continue
2199
- const key = `${edge.source}\0${edge.target}`
2200
- if (!byDirectedPair.has(key)) byDirectedPair.set(key, [])
2201
- byDirectedPair.get(key)!.push(edge)
2202
- }
2203
-
2204
- const separateMixedPair = (bucket: PositionedEdge[], back: boolean): boolean => {
2205
- if (bucket.length < 2) return false
2206
- if (!bucket.some(edge => edge.label) || !bucket.some(edge => !edge.label)) return false
2207
- if (!bucket.some(edge => edge.label && bucket.some(other => !other.label && routesShareCollinearRun(edge, other)))) return false
2208
- bucket.sort((a, b) => (a.edgeIndex ?? 0) - (b.edgeIndex ?? 0))
2209
- const source = nodeMap.get(bucket[0]!.source)!
2210
- const target = nodeMap.get(bucket[0]!.target)!
2211
- const exitMain = exitSide(source, back)
2212
- const entryMain = entrySide(target, back)
2213
- const k = bucket.length
2214
- const sep = Math.min(TARGET_SEP, (Math.min(crossSize(source), crossSize(target)) - 2 * MARGIN) / (k - 1))
2215
- if (sep < 4) return false
2216
- const sourceCenter = source[f.cross] + crossSize(source) / 2
2217
- const targetCenter = target[f.cross] + crossSize(target) / 2
2218
-
2219
- const lanes: Array<{ edge: PositionedEdge; sCross: number; tCross: number }> = []
2220
- const col: number[] = []
2221
- for (let i = 0; i < k; i++) {
2222
- const sCross = sourceCenter + (i - (k - 1) / 2) * sep
2223
- const tCross = targetCenter + (i - (k - 1) / 2) * sep
2224
- let jog = exitMain + (back ? -1 : 1) * f.sign * (BASE_GAP + i * sep)
2225
- const lo = Math.min(exitMain, entryMain), hi = Math.max(exitMain, entryMain)
2226
- if (jog <= lo + 4 || jog >= hi - 4) jog = (exitMain + entryMain) / 2
2227
- lanes.push({ edge: bucket[i]!, sCross, tCross })
2228
- col.push(jog)
2229
- }
2230
- if (lanes[0]!.tCross - lanes[0]!.sCross > 0) col.reverse()
2231
-
2232
- const proposed: Array<{ edge: PositionedEdge; points: Point[] }> = []
2233
- for (let i = 0; i < k; i++) {
2234
- const { edge, sCross, tCross } = lanes[i]!
2235
- const jog = col[i]!
2236
- const raw = Math.abs(sCross - tCross) <= 0.5
2237
- ? [mk(exitMain, sCross), mk(entryMain, tCross)]
2238
- : [mk(exitMain, sCross), mk(jog, sCross), mk(jog, tCross), mk(entryMain, tCross)]
2239
- const clipped = simplifyPolyline(
2240
- clipEdgeToShape(
2241
- clipEdgeToShape(raw, source, true),
2242
- target,
2243
- false,
2244
- ),
2245
- )
2246
- if (clipped.length < 2 ||
2247
- !onShapeOutline(source, clipped[0]!) ||
2248
- !onShapeOutline(target, clipped[clipped.length - 1]!) ||
2249
- !routeClearOfNodes(clipped, nodes, new Set([source.id, target.id]))) {
2250
- return false
2251
- }
2252
- proposed.push({ edge, points: clipped })
2253
- }
2254
- for (const { edge, points } of proposed) {
2255
- edge.points = points
2256
- edge.labelPosition = edge.label ? longestSegmentMidpoint(points) : undefined
2257
- edge.routeCertificate = undefined
2258
- bundled.add(edge)
2259
- preSeparated.add(edge)
2260
- }
2261
- return true
2262
- }
2263
-
2264
- for (const bucket of byDirectedPair.values()) {
2265
- const source = nodeMap.get(bucket[0]!.source)
2266
- const target = nodeMap.get(bucket[0]!.target)
2267
- if (!source || !target) continue
2268
- const back = !((entrySide(target, false) - exitSide(source, false)) * f.sign > BASE_GAP)
2269
- separateMixedPair(bucket, back)
2270
- }
2271
-
2272
- const fwdByTarget = new Map<string, PositionedEdge[]>()
2273
- const backByTarget = new Map<string, PositionedEdge[]>()
2274
- for (const edge of edges) {
2275
- if (preSeparated.has(edge)) continue
2276
- if (edge.source === edge.target || edge.label) continue
2277
- const source = nodeMap.get(edge.source)
2278
- const target = nodeMap.get(edge.target)
2279
- if (!source || !target) continue
2280
- if (!PORT_EXACT.has(source.shape) || !PORT_EXACT.has(target.shape)) continue
2281
- if (nodeInsideGroups(source, groups) || nodeInsideGroups(target, groups)) continue
2282
- // Forward: target sits ahead on the source's flow side, side-to-side with
2283
- // room for a lane. Feedback: target sits behind, so the edge enters the
2284
- // hub's flow-exit face. Anything else stays with its route contract.
2285
- const push = (m: Map<string, PositionedEdge[]>) => {
2286
- if (!m.has(edge.target)) m.set(edge.target, [])
2287
- m.get(edge.target)!.push(edge)
2288
- }
2289
- if ((entrySide(target, false) - exitSide(source, false)) * f.sign > BASE_GAP) push(fwdByTarget)
2290
- else if ((exitSide(source, true) - entrySide(target, true)) * f.sign > BASE_GAP) push(backByTarget)
2291
- }
2292
-
2293
- const distribute = (bucket: PositionedEdge[], back: boolean): void => {
2294
- if (bucket.length < 2) return
2295
- // Gate: act only when a real duplicate shares this face. Without one this is
2296
- // ordinary distinct fan-in, which merge-centering renders as a clean merge.
2297
- const srcCounts = new Map<string, number>()
2298
- for (const edge of bucket) srcCounts.set(edge.source, (srcCounts.get(edge.source) ?? 0) + 1)
2299
- if (![...srcCounts.values()].some(count => count >= 2)) return
2300
-
2301
- const target = nodeMap.get(bucket[0]!.target)!
2302
- const entryMain = entrySide(target, back)
2303
- const tCenter = target[f.cross] + crossSize(target) / 2
2304
-
2305
- // Order by source cross-position (duplicates from one source kept adjacent
2306
- // and stable by edgeIndex) so the assigned lanes never cross.
2307
- const srcCenter = (edge: PositionedEdge) => {
2308
- const s = nodeMap.get(edge.source)!
2309
- return s[f.cross] + crossSize(s) / 2
2310
- }
2311
- bucket.sort((a, b) => srcCenter(a) - srcCenter(b) || (a.edgeIndex ?? 0) - (b.edgeIndex ?? 0))
2312
-
2313
- const k = bucket.length
2314
- const sep = Math.min(TARGET_SEP, (crossSize(target) - 2 * MARGIN) / (k - 1))
2315
- if (sep < 4) return // too tight to separate meaningfully — leave as-is
2316
-
2317
- // Duplicates from the same source also need distinct exit lanes on their
2318
- // shared source side, else their source endpoints would overlap.
2319
- const bySource = new Map<string, PositionedEdge[]>()
2320
- for (const edge of bucket) {
2321
- if (!bySource.has(edge.source)) bySource.set(edge.source, [])
2322
- bySource.get(edge.source)!.push(edge)
2323
- }
2324
-
2325
- // Lay out each lane (source exit slot, target entry slot, riser column),
2326
- // then order the riser columns so duplicate lanes NEST instead of crossing.
2327
- const lanes: Array<{ edge: PositionedEdge; source: PositionedNode; exitMain: number; sCross: number; tCross: number }> = []
2328
- const col: number[] = []
2329
- for (let i = 0; i < k; i++) {
2330
- const edge = bucket[i]!
2331
- const source = nodeMap.get(edge.source)!
2332
- const exitMain = exitSide(source, back)
2333
- const tCross = tCenter + (i - (k - 1) / 2) * sep
2334
-
2335
- const sibs = bySource.get(edge.source)!
2336
- const sSep = sibs.length > 1
2337
- ? Math.min(sep, (crossSize(source) - 2 * MARGIN) / (sibs.length - 1))
2338
- : 0
2339
- const sCross = source[f.cross] + crossSize(source) / 2 + (sibs.indexOf(edge) - (sibs.length - 1) / 2) * sSep
2340
-
2341
- // Each edge jogs at its own column, staggered by the lane separation, so
2342
- // the cross-axis segments stay parallel rather than overlapping. Feedback
2343
- // edges run against the flow, so their jog steps the other way.
2344
- let jog = exitMain + (back ? -1 : 1) * f.sign * (BASE_GAP + i * sep)
2345
- const lo = Math.min(exitMain, entryMain), hi = Math.max(exitMain, entryMain)
2346
- if (jog <= lo + 4 || jog >= hi - 4) jog = (exitMain + entryMain) / 2
2347
- lanes.push({ edge, source, exitMain, sCross, tCross })
2348
- col.push(jog)
2349
- }
2350
-
2351
- // Nest riser columns within each run of duplicate lanes (same source, hence
2352
- // same directed pair in this target bucket). When the pair bends across the
2353
- // flow, the UPPER lane's riser must sit farther out so the lower lane's exit
2354
- // run does not cut across it (an upward bend is the mirror, handled by the
2355
- // reversal below). Endpoints are untouched, so separation/order is preserved.
2356
- for (let a = 0; a < k;) {
2357
- let b = a
2358
- while (b + 1 < k && bucket[b + 1]!.source === bucket[a]!.source) b++
2359
- if (b > a && lanes[a]!.tCross - lanes[a]!.sCross > 0) {
2360
- const slice = col.slice(a, b + 1).reverse()
2361
- for (let r = a; r <= b; r++) col[r] = slice[r - a]!
2362
- }
2363
- a = b + 1
2364
- }
2365
-
2366
- const proposed: Array<{ edge: PositionedEdge; points: Point[] }> = []
2367
- let ok = true
2368
- for (let i = 0; i < k; i++) {
2369
- const { edge, source, exitMain, sCross, tCross } = lanes[i]!
2370
- const jog = col[i]!
2371
- let pts: Point[]
2372
- if (Math.abs(sCross - tCross) <= 0.5) {
2373
- pts = [mk(exitMain, sCross), mk(entryMain, tCross)]
2374
- } else {
2375
- pts = [mk(exitMain, sCross), mk(jog, sCross), mk(jog, tCross), mk(entryMain, tCross)]
2376
- }
2377
- pts = clipEdgeToShape(pts, source, true)
2378
- pts = clipEdgeToShape(pts, target, false)
2379
- pts = simplifyPolyline(pts)
2380
- if (pts.length < 2 ||
2381
- !onShapeOutline(source, pts[0]!) ||
2382
- !onShapeOutline(target, pts[pts.length - 1]!) ||
2383
- !routeClearOfNodes(pts, nodes, new Set([source.id, target.id]))) {
2384
- ok = false
2385
- break
2386
- }
2387
- proposed.push({ edge, points: pts })
2388
- }
2389
- if (!ok) return
2390
- for (const { edge, points } of proposed) {
2391
- edge.points = points
2392
- edge.labelPosition = undefined
2393
- edge.routeCertificate = undefined
2394
- bundled.add(edge)
2395
- }
2396
- }
2397
-
2398
- for (const bucket of fwdByTarget.values()) distribute(bucket, false)
2399
- for (const bucket of backByTarget.values()) distribute(bucket, true)
2400
- }
2401
-
2402
- /**
2403
- * Recursively extract edges from ELK result including those inside subgraphs.
2404
- * Edges are distributed to subgraphs for direction override to work,
2405
- * so we need to collect them from all levels with proper coordinate offsets.
2406
- *
2407
- * For hierarchical edges (cross-hierarchy with ports), combines external and
2408
- * internal segments into a single continuous edge path.
2409
- */
2410
- export function extractEdgesRecursively(
2411
- elkNode: ElkNode,
2412
- graph: MermaidGraph,
2413
- edges: PositionedEdge[],
2414
- offsetX: number,
2415
- offsetY: number,
2416
- margins?: MarginInfo
2417
- ): void {
2418
- // First pass: collect all edge segments
2419
- const segments = new Map<number, { external?: EdgeSegment; incoming?: EdgeSegment; outgoing?: EdgeSegment }>()
2420
- collectEdgeSegments(elkNode, segments, 0, 0)
2421
-
2422
- // Track margin-routed edge count for spacing offsets
2423
- let marginEdgeIndex = 0
2424
-
2425
- // Second pass: combine segments and create positioned edges
2426
- for (const [edgeIndex, seg] of segments) {
2427
- const originalEdge = graph.edges[edgeIndex]
2428
- if (!originalEdge) continue
2429
-
2430
- // Combine points from all segments in correct order:
2431
- // - For incoming cross-hierarchy (external → subgraph): external then incoming
2432
- // - For outgoing cross-hierarchy (subgraph → external): outgoing then external
2433
- // - For both (subgraph A → subgraph B): outgoing → external → incoming
2434
- const allPoints: Point[] = []
2435
-
2436
- // First: outgoing internal segment (source node → exit port)
2437
- if (seg.outgoing && seg.outgoing.points.length > 0) {
2438
- allPoints.push(...seg.outgoing.points)
2439
- }
2440
-
2441
- // Second: external segment (exit port → entry port, or source → entry port, or exit port → target)
2442
- if (seg.external && seg.external.points.length > 0) {
2443
- if (allPoints.length > 0) {
2444
- // Skip first point to avoid duplicate at outgoing port
2445
- allPoints.push(...seg.external.points.slice(1))
2446
- } else {
2447
- allPoints.push(...seg.external.points)
2448
- }
2449
- }
2450
-
2451
- // Third: incoming internal segment (entry port → target node)
2452
- if (seg.incoming && seg.incoming.points.length > 0) {
2453
- if (allPoints.length > 0) {
2454
- // Skip first point to avoid duplicate at incoming port
2455
- allPoints.push(...seg.incoming.points.slice(1))
2456
- } else {
2457
- allPoints.push(...seg.incoming.points)
2458
- }
2459
- }
2460
-
2461
- // Label position: use ELK's inline label position (on-edge with collision avoidance)
2462
- // Fall back to midpoint for hierarchical edges or when ELK position unavailable
2463
- let labelPosition: Point | undefined
2464
- if (originalEdge.label && allPoints.length >= 2) {
2465
- const elkLabelPos = seg.external?.labelPosition
2466
- labelPosition = elkLabelPos ?? calculatePathMidpoint(allPoints)
2467
- }
2468
-
2469
- // Ensure all edge segments are orthogonal (horizontal or vertical only).
2470
- // In SEPARATE hierarchy mode, ELK may produce diagonal segments for
2471
- // cross-hierarchy edges where it only returns start/end points without
2472
- // proper orthogonal bend points.
2473
- // When margins are available, route through the diagram margins instead
2474
- // of Z-paths through the middle (which cross through subgraphs).
2475
- const orthogonalPoints = orthogonalizeEdgePoints(allPoints, margins, marginEdgeIndex)
2476
- if (orthogonalPoints !== allPoints) {
2477
- marginEdgeIndex++
2478
- }
2479
-
2480
- // Recalculate label position for margin-routed edges
2481
- if (originalEdge.label && orthogonalPoints !== allPoints && orthogonalPoints.length >= 2) {
2482
- labelPosition = calculatePathMidpoint(orthogonalPoints)
2483
- }
2484
-
2485
- edges.push({
2486
- source: originalEdge.source,
2487
- target: originalEdge.target,
2488
- label: originalEdge.label,
2489
- style: originalEdge.style,
2490
- hasArrowStart: originalEdge.hasArrowStart,
2491
- hasArrowEnd: originalEdge.hasArrowEnd,
2492
- startMarker: originalEdge.startMarker,
2493
- endMarker: originalEdge.endMarker,
2494
- points: orthogonalPoints,
2495
- labelPosition,
2496
- inlineStyle: resolveEdgeInlineStyle(edgeIndex, graph),
2497
- edgeIndex,
2498
- })
2499
- }
2500
- }
2501
-
2502
- /**
2503
- * Post-process edge points to ensure all segments are purely orthogonal.
2504
- *
2505
- * When ELK uses SEPARATE hierarchy handling (required for subgraph direction
2506
- * overrides), cross-hierarchy edges may only get start/end coordinates without
2507
- * intermediate bend points, producing diagonal lines.
2508
- *
2509
- * When margins are provided, routes diagonal segments through the left or right
2510
- * margin of the diagram (outside all subgraphs). Alternates sides and adds
2511
- * spacing offsets to prevent overlapping parallel edges.
2512
- *
2513
- * Without margins, falls back to Z-path through the vertical midpoint.
2514
- *
2515
- * Returns the original array reference (identity) if no changes were needed,
2516
- * so callers can detect whether routing was applied.
2517
- */
2518
- export function orthogonalizeEdgePoints(
2519
- points: Point[],
2520
- margins?: MarginInfo,
2521
- edgeIndex: number = 0
2522
- ): Point[] {
2523
- if (points.length < 2) return points
2524
-
2525
- // Check if any segment needs orthogonalization
2526
- let needsWork = false
2527
- for (let i = 1; i < points.length; i++) {
2528
- const dx = Math.abs(points[i]!.x - points[i - 1]!.x)
2529
- const dy = Math.abs(points[i]!.y - points[i - 1]!.y)
2530
- if (dx > 1 && dy > 1) { needsWork = true; break }
2531
- }
2532
- if (!needsWork) return points
2533
-
2534
- const EDGE_SPACING = 12
2535
- const result: Point[] = [points[0]!]
2536
-
2537
- for (let i = 1; i < points.length; i++) {
2538
- const prev = result[result.length - 1]!
2539
- const curr = points[i]!
2540
- const dx = Math.abs(curr.x - prev.x)
2541
- const dy = Math.abs(curr.y - prev.y)
2542
-
2543
- if (dx > 1 && dy > 1) {
2544
- if (margins) {
2545
- // Margin routing: exit horizontally → travel vertically along margin → enter horizontally
2546
- // Alternate left/right margins and offset for parallel edge spacing
2547
- const useRight = edgeIndex % 2 === 0
2548
- const offset = Math.floor(edgeIndex / 2) * EDGE_SPACING
2549
- const marginX = useRight
2550
- ? margins.rightX + offset
2551
- : margins.leftX - offset
2552
-
2553
- result.push({ x: marginX, y: prev.y })
2554
- result.push({ x: marginX, y: curr.y })
2555
- } else {
2556
- // Fallback: Z-path through vertical midpoint
2557
- const midY = (prev.y + curr.y) / 2
2558
- result.push({ x: prev.x, y: midY })
2559
- result.push({ x: curr.x, y: midY })
2560
- }
2561
- }
2562
-
2563
- result.push(curr)
2564
- }
2565
-
2566
- return result
2567
- }
2568
-
2569
- /**
2570
- * Recursively collect edge segments from ELK result.
2571
- */
2572
- function collectEdgeSegments(
2573
- elkNode: ElkNode,
2574
- segments: Map<number, { external?: EdgeSegment; incoming?: EdgeSegment; outgoing?: EdgeSegment }>,
2575
- offsetX: number,
2576
- offsetY: number
2577
- ): void {
2578
- if (elkNode.edges) {
2579
- for (const elkEdge of elkNode.edges) {
2580
- // Parse edge ID: "e{index}" or "e{index}_internal"
2581
- const isInternal = elkEdge.id.endsWith('_internal')
2582
- const edgeIndex = parseInt(elkEdge.id.substring(1), 10)
2583
- if (isNaN(edgeIndex)) continue
2584
-
2585
- // Extract points
2586
- const points: Point[] = []
2587
- if (elkEdge.sections && elkEdge.sections.length > 0) {
2588
- const section = elkEdge.sections[0]!
2589
- points.push({
2590
- x: section.startPoint.x + offsetX,
2591
- y: section.startPoint.y + offsetY,
2592
- })
2593
- if (section.bendPoints) {
2594
- for (const bp of section.bendPoints) {
2595
- points.push({ x: bp.x + offsetX, y: bp.y + offsetY })
2596
- }
2597
- }
2598
- points.push({
2599
- x: section.endPoint.x + offsetX,
2600
- y: section.endPoint.y + offsetY,
2601
- })
2602
- }
2603
-
2604
- // Extract label position
2605
- let labelPosition: Point | undefined
2606
- if (elkEdge.labels && elkEdge.labels.length > 0) {
2607
- const label = elkEdge.labels[0]!
2608
- if (label.x != null && label.y != null) {
2609
- labelPosition = {
2610
- x: label.x + (label.width ?? 0) / 2 + offsetX,
2611
- y: label.y + (label.height ?? 0) / 2 + offsetY,
2612
- }
2613
- }
2614
- }
2615
-
2616
- // Store segment
2617
- if (!segments.has(edgeIndex)) {
2618
- segments.set(edgeIndex, {})
2619
- }
2620
- const seg = segments.get(edgeIndex)!
2621
-
2622
- if (isInternal) {
2623
- // Determine if this is an incoming or outgoing internal segment
2624
- // by checking if source is a port (incoming) or target is a port (outgoing)
2625
- const source = elkEdge.sources?.[0] ?? ''
2626
- const target = elkEdge.targets?.[0] ?? ''
2627
- const sourceIsPort = source.includes('_in_') || source.includes('_out_')
2628
- const targetIsPort = target.includes('_in_') || target.includes('_out_')
2629
-
2630
- if (sourceIsPort) {
2631
- // Port → node: incoming internal segment
2632
- seg.incoming = { edgeIndex, isInternal, points, labelPosition }
2633
- } else if (targetIsPort) {
2634
- // Node → port: outgoing internal segment
2635
- seg.outgoing = { edgeIndex, isInternal, points, labelPosition }
2636
- }
2637
- } else {
2638
- seg.external = { edgeIndex, isInternal, points, labelPosition }
2639
- }
2640
- }
2641
- }
2642
-
2643
- // Recurse into children with accumulated offset
2644
- if (elkNode.children) {
2645
- for (const child of elkNode.children) {
2646
- collectEdgeSegments(child, segments, offsetX + (child.x ?? 0), offsetY + (child.y ?? 0))
2647
- }
2648
- }
2649
- }
2650
-
2651
- /**
2652
- * Port-lane alignment, the placement repair for "straight but off-port":
2653
- * a 2-point flow-axis edge whose endpoints' midpoint ports sit on different
2654
- * cross lanes is floating through the overlap sliver of two side spans —
2655
- * ELK's BALANCED node placement averages competing pulls and aligns such a
2656
- * node with neither neighbor. Sliding ONE endpoint node by the lane
2657
- * difference makes the edge midpoint-to-midpoint: port-exact AND straight,
2658
- * the combination no routing-side repair can reach (port-aware coordinate
2659
- * assignment in the Brandes–Köpf tradition, Rüegg et al. GD'15).
2660
- *
2661
- * Every move is proof-gated, mirroring alignLayerNodes' occlusion doctrine:
2662
- * - only rect-like endpoints (their flow-side boundary is flat, so the
2663
- * edge's main-axis anchors survive a cross slide);
2664
- * - the slid node's OTHER edges must be bent with a perpendicular segment
2665
- * to absorb the shift (their terminal run translates); a second straight
2666
- * edge or a group membership vetoes the move;
2667
- * - the new bbox must keep clear of nodes, foreign edge corridors, and
2668
- * label pills.
2669
- * The target node is tried first (often alone in its layer, the freest),
2670
- * then the source.
2671
- */
2672
- /**
2673
- * Restore the source mid-port for a labelled edge that ELK pushed off it.
2674
- *
2675
- * When a labelled edge is handed to ELK it reserves a cell for the inline label
2676
- * dummy, and that cell displaces the edge's lane off the source node's mid-port:
2677
- * the edge stays straight but exits the source off-centre (the "warnings →
2678
- * warnings line not using the mid-point port" defect). alignPortLanes would
2679
- * slide the node to fix exactly this, but every one of its slide loops EXCLUDES
2680
- * labelled edges (`if (e.label) continue`), so the labelled case is never
2681
- * repaired — which is what made global label-decoupling look necessary.
2682
- *
2683
- * The source's exit is whatever cross-LANE the certifying straightener
2684
- * (applyRouteContracts) finally settles the edge onto — and that lane is chosen
2685
- * downstream: it depends on the target's span, the label pill's fit, and any
2686
- * fan-in. Aligning the source's mid to the TARGET's mid (the previous fix) only
2687
- * works when the straightener happens to pick the shared mid lane; with a wide
2688
- * label the pill cannot sit on the centred lane (so the straightener drops to an
2689
- * off-centre lane), and with a fan-in skew the mids are too far apart for any
2690
- * single lane to be port-exact at both ends. Both leave the source off-port.
2691
- *
2692
- * The general fix is to move the source onto the lane the straightener WILL use,
2693
- * BEFORE the freeze, so the exit lands on the moved source's mid-port. We ask
2694
- * the straightener's own read-only predictor (`straightLaneFor`) which lane it
2695
- * will pick, iterating to a fixed point because moving the source shifts its
2696
- * attach span (which can change the feasible lane). Then we slide the source by
2697
- * that delta and re-anchor its exit endpoint exactly onto the mid-port. The
2698
- * source may carry incoming edges (degree >= 2): their terminal run translates
2699
- * with the node (anchored at the far end, the same machinery alignPortLanes
2700
- * uses), so no edge is dragged off its other endpoint. Gated on the slid box
2701
- * clearing every other node and foreign edge corridor (the occlusion doctrine
2702
- * the other slides enforce); the independent hard-invariant gate is the backstop.
2703
- */
2704
- export function alignLabeledSourcePort(
2705
- nodes: PositionedNode[],
2706
- edges: PositionedEdge[],
2707
- groups: PositionedGroup[],
2708
- graph: MermaidGraph,
2709
- direction: Direction,
2710
- style: LabelMetricsStyle,
2711
- ): void {
2712
- if (layoutEnvFlag('APL_NO_LABELED_SOURCE_PORT')) return
2713
- // A labeled source feeding a co-ranked, symmetrized mixed-label fan-in hub
2714
- // must YIELD: its spoke is now part of a symmetric convergence (the hub is
2715
- // centered on its sources' barycenter and the spokes are mirror doglegs).
2716
- // Re-straightening it onto the source mid-port would pull the spoke off the
2717
- // converged shape and de-center the hub — the older "labeled source exits
2718
- // straight at mid-port" contract is, for a fan-in, superseded by the
2719
- // symmetric-convergence principle (the spoke still EXITS at the mid-port, it
2720
- // just bends to converge). A labeled source whose target is single-input (no
2721
- // fan-in) is unaffected and still straightened below. We skip the hub's whole
2722
- // incoming spoke set via the shared corankFanInSpokes predicate, so this pass,
2723
- // the bend exemption, and the hitch oracle all agree on the same spokes.
2724
- const corankFanInTargets = new Set<string>()
2725
- for (const [hubId] of corankFanInSpokes(nodes, edges, groups, graph)) corankFanInTargets.add(hubId)
2726
- const isHorizontal = direction === 'LR' || direction === 'RL'
2727
- const cross = isHorizontal ? ('y' as const) : ('x' as const)
2728
- const main = isHorizontal ? ('x' as const) : ('y' as const)
2729
- const exitSide: 'N' | 'E' | 'S' | 'W' = isHorizontal
2730
- ? (direction === 'LR' ? 'E' : 'W')
2731
- : (direction === 'BT' ? 'N' : 'S')
2732
- const crossSize = (n: PositionedNode) => (isHorizontal ? n.height : n.width)
2733
- const portCross = (n: PositionedNode) => n[cross] + crossSize(n) / 2
2734
- // Clear actual overlap only (the slide moves a node to meet its own edge; a
2735
- // tight gap to an unrelated node is acceptable, and the independent
2736
- // hard-invariant gate is the real backstop against any overlap regression).
2737
- const GAP = 2
2738
-
2739
- const flat: PositionedGroup[] = []
2740
- const collect = (gs: PositionedGroup[]) => { for (const g of gs) { flat.push(g); collect(g.children) } }
2741
- collect(groups)
2742
- const inGroup = (n: PositionedNode) => flat.some(g =>
2743
- n.x >= g.x - 0.5 && n.y >= g.y - 0.5 &&
2744
- n.x + n.width <= g.x + g.width + 0.5 && n.y + n.height <= g.y + g.height + 0.5)
2745
-
2746
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
2747
- // The exit-side main coordinate of S's forward port (right edge for LR, etc).
2748
- const exitMain = (n: PositionedNode) => (main === 'x'
2749
- ? (exitSide === 'E' ? n.x + n.width : n.x)
2750
- : (exitSide === 'S' ? n.y + n.height : n.y))
2751
-
2752
- // Gather S plus every upstream predecessor that must travel WITH it so its
2753
- // own incoming edge keeps its mid-port: each single-out, PORT_EXACT and
2754
- // ungrouped. Returns null if the chain is fed by a fan-out hub or an
2755
- // unmovable single-out source — we then leave S put rather than knock another
2756
- // source off its port (the degree-2 defect the narrow slide used to create).
2757
- const gatherMovableChain = (root: PositionedNode, labelled: PositionedEdge): Set<string> | null => {
2758
- const set = new Set<string>([root.id])
2759
- const stack = [root.id]
2760
- while (stack.length) {
2761
- const nId = stack.pop()!
2762
- for (const o of edges) {
2763
- if (o.target !== nId || o === labelled) continue // incoming edges only
2764
- const P = nodeMap.get(o.source)
2765
- if (!P || set.has(P.id)) continue
2766
- if (edges.filter(x => x.source === P.id).length !== 1) return null // fan-out hub upstream
2767
- if (!PORT_EXACT.has(P.shape) || inGroup(P)) return null // unmovable source
2768
- set.add(P.id); stack.push(P.id)
2769
- }
2770
- }
2771
- return set
2772
- }
2773
-
2774
- for (const e of edges) {
2775
- if (!e.label || e.points.length < 2 || e.source === e.target) continue
2776
- // Yield to a symmetric mixed-label fan-in: never re-straighten a spoke that
2777
- // converges into a co-ranked, centered hub (see the note at the top).
2778
- if (corankFanInTargets.has(e.target)) continue
2779
- const S = nodeMap.get(e.source)
2780
- if (!S || !PORT_EXACT.has(S.shape) || inGroup(S)) continue
2781
- // S must emit exactly this one forward edge — its lane is then S's sole
2782
- // claim on the exit side, so re-centring it onto that lane is unambiguous.
2783
- // (S MAY have incoming edges; their nodes translate with the slide.)
2784
- if (edges.some(o => o !== e && o.source === S.id && o.target !== S.id)) continue
2785
- // The edge must actually leave S from its forward exit side, so sliding S
2786
- // along the cross axis re-centres a genuine side-exit (not a corner stub).
2787
- if (Math.abs(e.points[0]![main] - exitMain(S)) > 1) continue
2788
- const T = nodeMap.get(e.target)
2789
- if (!T || !PORT_EXACT.has(T.shape) || inGroup(T)) continue
2790
-
2791
- // Find the lane the straightener will settle e onto, then the delta to put
2792
- // S's mid there. Moving S shifts its attach span, which can change the
2793
- // feasible lane, so iterate to a fixed point (converges in 1-2 rounds; the
2794
- // candidate set is discrete). Probe on a CLONE of S so the search never
2795
- // mutates committed geometry; commit only the converged delta.
2796
- const { ctx, axis } = laneContextFor(nodes, edges, direction, style)
2797
- let delta = 0
2798
- let converged = false
2799
- const probe = { ...S }
2800
- for (let iter = 0; iter < 4; iter++) {
2801
- const lane = straightLaneFor(e, probe, T, ctx, axis)
2802
- if (lane === null) break
2803
- const step = lane - portCross(probe)
2804
- delta += step
2805
- probe[cross] += step
2806
- if (Math.abs(step) <= 0.5) { converged = true; break }
2807
- }
2808
- if (!converged || Math.abs(delta) <= 1) continue
2809
-
2810
- // S's slide must carry its whole upstream chain, so no incoming source is
2811
- // left off its mid-port. Gather it; abort the slide if it can't move cleanly.
2812
- const chain = gatherMovableChain(S, e)
2813
- if (!chain) continue
2814
- const chainNodes = [...chain].map(id => nodeMap.get(id)).filter(Boolean) as PositionedNode[]
2815
- const movedRect = (n: PositionedNode) => ({
2816
- x: isHorizontal ? n.x : n.x + delta,
2817
- y: isHorizontal ? n.y + delta : n.y,
2818
- width: n.width, height: n.height,
2819
- })
2820
- // Every moved box must clear every UNMOVED node (with margin) and not cut a
2821
- // foreign edge corridor — never create a node overlap or edge-through-node.
2822
- const hitsNode = nodes.some(o => !chain.has(o.id) && chainNodes.some(n => {
2823
- const m = movedRect(n)
2824
- return m.x - GAP < o.x + o.width && m.x + m.width + GAP > o.x &&
2825
- m.y - GAP < o.y + o.height && m.y + m.height + GAP > o.y
2826
- }))
2827
- if (hitsNode) continue
2828
- // Intra-chain edges move WITH their nodes, so they are exempt from the
2829
- // foreign-corridor cut test; only edges incident to no moved node count.
2830
- const cutsEdge = edges.some(o => !chain.has(o.source) && !chain.has(o.target) &&
2831
- chainNodes.some(n => {
2832
- const m = movedRect(n)
2833
- return o.points.some((p, i) => {
2834
- if (i === 0) return false
2835
- const q = o.points[i - 1]!
2836
- return Math.max(p.x, q.x) > m.x + 0.5 && Math.min(p.x, q.x) < m.x + m.width - 0.5 &&
2837
- Math.max(p.y, q.y) > m.y + 0.5 && Math.min(p.y, q.y) < m.y + m.height - 0.5
2838
- })
2839
- }))
2840
- if (cutsEdge) continue
2841
-
2842
- // Commit: translate every chain node, and every intra-chain edge, rigidly by
2843
- // delta — so each incoming edge stays straight and exits its source at mid.
2844
- for (const n of chainNodes) { if (isHorizontal) n.y += delta; else n.x += delta }
2845
- for (const o of edges) {
2846
- if (chain.has(o.source) && chain.has(o.target)) for (const p of o.points) p[cross] += delta
2847
- }
2848
- e.points[0] = shapePorts(S)[exitSide]
2849
- }
2850
- }
2851
-
2852
- export function alignPortLanes(
2853
- nodes: PositionedNode[],
2854
- edges: PositionedEdge[],
2855
- groups: PositionedGroup[],
2856
- direction: Direction,
2857
- style: LabelMetricsStyle,
2858
- ): void {
2859
- const isHorizontal = direction === 'LR' || direction === 'RL'
2860
- const cross = isHorizontal ? ('y' as const) : ('x' as const)
2861
- const main = isHorizontal ? ('x' as const) : ('y' as const)
2862
- const sign = direction === 'LR' || direction === 'TD' ? 1 : -1
2863
-
2864
- // A node that took part in an applied alignment is FROZEN: sliding it for
2865
- // a later candidate would re-break the proven port-aligned relation.
2866
- const frozen = new Set<string>()
2867
- const MARGIN = 8
2868
- const crossSize = (n: PositionedNode) => (isHorizontal ? n.height : n.width)
2869
- const portCross = (n: PositionedNode) => n[cross] + crossSize(n) / 2
2870
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
2871
-
2872
- const flatGroups: PositionedGroup[] = []
2873
- const collect = (gs: PositionedGroup[]) => {
2874
- for (const g of gs) {
2875
- flatGroups.push(g)
2876
- collect(g.children)
2877
- }
2878
- }
2879
- collect(groups)
2880
- const inGroup = (n: PositionedNode) => flatGroups.some(g =>
2881
- n.x >= g.x - 0.5 && n.y >= g.y - 0.5 &&
2882
- n.x + n.width <= g.x + g.width + 0.5 && n.y + n.height <= g.y + g.height + 0.5)
2883
-
2884
- const overlaps = (ax: number, ay: number, aw: number, ah: number,
2885
- bx: number, by: number, bw: number, bh: number) =>
2886
- ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by
2887
-
2888
- const mainSize = (n: PositionedNode) => (isHorizontal ? n.width : n.height)
2889
- /** A lane at `crossVal` over [lo, hi] on the main axis must keep CLEARANCE
2890
- * from every node not in `exclude` — the same obstacle rule the
2891
- * certifying straightener proves with, applied BEFORE the slide. */
2892
- const laneBlocked = (crossVal: number, lo: number, hi: number, exclude: ReadonlySet<string>): boolean =>
2893
- nodes.some(n => !exclude.has(n.id) &&
2894
- crossVal > n[cross] - 4 && crossVal < n[cross] + crossSize(n) + 4 &&
2895
- hi > n[main] - 4 && lo < n[main] + mainSize(n) + 4)
2896
-
2897
- /** A lane along the CROSS axis at fixed main coordinate (a hop/stub). */
2898
- const hopBlocked = (mainVal: number, lo: number, hi: number, exclude: ReadonlySet<string>): boolean =>
2899
- nodes.some(n => !exclude.has(n.id) &&
2900
- mainVal > n[main] - 4 && mainVal < n[main] + mainSize(n) + 4 &&
2901
- hi > n[cross] - 4 && lo < n[cross] + crossSize(n) + 4)
2902
-
2903
- /** The perpendicular segment adjoining the terminal run: when the run
2904
- * translates by delta, this segment STRETCHES across the swept corridor
2905
- * [ref+delta, its far cross] — which must be proved clear like any lane.
2906
- * Returns null when the run consumes the whole polyline (no hop). */
2907
- const hopAfterRun = (pts: Point[], fromEnd: boolean): { mainPos: number; far: number; orthogonal: boolean } | null => {
2908
- const idx = fromEnd ? pts.length - 1 : 0
2909
- const step = fromEnd ? -1 : 1
2910
- const ref = pts[idx]![cross]
2911
- let i = idx
2912
- while (i >= 0 && i < pts.length && Math.abs(pts[i]![cross] - ref) <= 0.5) i += step
2913
- if (i < 0 || i >= pts.length) return null
2914
- const beyond = pts[i]!
2915
- const edgeOfRun = pts[i - step]!
2916
- return { mainPos: beyond[main], far: beyond[cross], orthogonal: Math.abs(beyond[main] - edgeOfRun[main]) <= 0.5 }
2917
- }
2918
-
2919
- /** The terminal run a slide would translate: its lane and main extent. */
2920
- const runExtent = (pts: Point[], fromEnd: boolean): { ref: number; lo: number; hi: number } => {
2921
- const idx = fromEnd ? pts.length - 1 : 0
2922
- const step = fromEnd ? -1 : 1
2923
- const ref = pts[idx]![cross]
2924
- let lo = Infinity
2925
- let hi = -Infinity
2926
- for (let i = idx; i >= 0 && i < pts.length; i += step) {
2927
- if (Math.abs(pts[i]![cross] - ref) > 0.5) break
2928
- lo = Math.min(lo, pts[i]![main])
2929
- hi = Math.max(hi, pts[i]![main])
2930
- }
2931
- return { ref, lo, hi }
2932
- }
2933
-
2934
- const moveSafe = (node: PositionedNode, delta: number, aligned: PositionedEdge): boolean => {
2935
- if (frozen.has(node.id) || inGroup(node)) return false
2936
- // The slide only helps if the resulting shared port lane is provably
2937
- // clear of intermediate nodes — otherwise the straightener cannot
2938
- // collapse onto it and the translated run may cut THROUGH a node (the
2939
- // hard defect this pipeline exists to prevent).
2940
- const srcN = nodeMap.get(aligned.source)!
2941
- const tgtN = nodeMap.get(aligned.target)!
2942
- const lane = portCross(node.id === aligned.source ? tgtN : srcN)
2943
- const laneLo = Math.min(srcN[main] + mainSize(srcN) / 2, tgtN[main] + mainSize(tgtN) / 2)
2944
- const laneHi = Math.max(srcN[main] + mainSize(srcN) / 2, tgtN[main] + mainSize(tgtN) / 2)
2945
- if (laneBlocked(lane, laneLo, laneHi, new Set([aligned.source, aligned.target]))) return false
2946
- for (const e of edges) {
2947
- const srcTouch = e.source === node.id
2948
- const tgtTouch = e.target === node.id
2949
- if (!srcTouch && !tgtTouch) continue
2950
- const isAligned = e === aligned
2951
- if (isAligned && e.points.length === 2 &&
2952
- Math.abs(e.points[0]![cross] - e.points[1]![cross]) <= 0.5) continue // re-anchored exactly
2953
- if (!isAligned) {
2954
- if (e.points.length < 3) return false // a straight sibling cannot absorb the slide
2955
- // The terminal run translates; a perpendicular segment must exist to
2956
- // stretch, or the shift would drag the far endpoint off its node.
2957
- const anchor = tgtTouch ? e.points[e.points.length - 1]! : e.points[0]!
2958
- if (!e.points.some(p => Math.abs(p[cross] - anchor[cross]) > 0.5)) return false
2959
- }
2960
- const exclude = new Set([e.source, e.target])
2961
- // The translated run must not land on a node...
2962
- const run = runExtent(e.points, tgtTouch)
2963
- if (laneBlocked(run.ref + delta, run.lo, run.hi, exclude)) return false
2964
- // ...and the adjoining perpendicular segment stretches across the
2965
- // swept corridor, which must also be node-free (the property oracle
2966
- // caught a 123px slide sweeping a sibling's hop through a circle).
2967
- const hop = hopAfterRun(e.points, tgtTouch)
2968
- if (hop) {
2969
- if (!hop.orthogonal) return false
2970
- const lo = Math.min(run.ref + delta, hop.far)
2971
- const hi = Math.max(run.ref + delta, hop.far)
2972
- if (hopBlocked(hop.mainPos, lo, hi, exclude)) return false
2973
- }
2974
- }
2975
- const nx = isHorizontal ? node.x : node.x + delta
2976
- const ny = isHorizontal ? node.y + delta : node.y
2977
- for (const other of nodes) {
2978
- if (other === node) continue
2979
- if (overlaps(nx - MARGIN, ny - MARGIN, node.width + 2 * MARGIN, node.height + 2 * MARGIN,
2980
- other.x, other.y, other.width, other.height)) return false
2981
- }
2982
- for (const e of edges) {
2983
- if (e.source === node.id || e.target === node.id) continue
2984
- for (let i = 1; i < e.points.length; i++) {
2985
- const a = e.points[i - 1]!, b = e.points[i]!
2986
- if (Math.max(a.x, b.x) > nx + 0.5 && Math.min(a.x, b.x) < nx + node.width - 0.5 &&
2987
- Math.max(a.y, b.y) > ny + 0.5 && Math.min(a.y, b.y) < ny + node.height - 0.5) return false
2988
- }
2989
- }
2990
- for (const e of edges) {
2991
- if (e === aligned) continue
2992
- const rect = labelRect(e, style)
2993
- if (rect && overlaps(nx - MARGIN, ny - MARGIN, node.width + 2 * MARGIN, node.height + 2 * MARGIN,
2994
- rect.x, rect.y, rect.w, rect.h)) return false
2995
- }
2996
- return true
2997
- }
2998
-
2999
- // `anchorFar` keeps the endpoint OPPOSITE the shifted run pinned. A straight
3000
- // 2-point edge is a single run spanning the whole polyline, so an unguarded
3001
- // shift drags its far endpoint off a node that is NOT moving (issue #62: the
3002
- // hub slide rigidly translated the straight duplicate edges feeding the hub,
3003
- // pulling their source endpoints off the source node's side). Anchoring the
3004
- // far end leaves a diagonal stub that applyRouteContracts re-anchors onto the
3005
- // merged port lane. Staircase runs never reach the far endpoint, so the flag
3006
- // is a no-op for them.
3007
- const shiftRun = (e: PositionedEdge, fromEnd: boolean, delta: number, anchorFar = false): void => {
3008
- const pts = e.points
3009
- const idx = fromEnd ? pts.length - 1 : 0
3010
- const step = fromEnd ? -1 : 1
3011
- const farIdx = fromEnd ? 0 : pts.length - 1
3012
- const ref = pts[idx]![cross]
3013
- let mainLo = Infinity
3014
- let mainHi = -Infinity
3015
- for (let i = idx; i >= 0 && i < pts.length; i += step) {
3016
- if (Math.abs(pts[i]![cross] - ref) > 0.5) break
3017
- if (anchorFar && i === farIdx) break
3018
- mainLo = Math.min(mainLo, pts[i]![main])
3019
- mainHi = Math.max(mainHi, pts[i]![main])
3020
- pts[i]![cross] += delta
3021
- }
3022
- // A label pill riding the shifted run must follow its lane.
3023
- if (e.labelPosition &&
3024
- Math.abs(e.labelPosition[cross] - ref) <= 12 &&
3025
- e.labelPosition[main] >= mainLo - 0.5 && e.labelPosition[main] <= mainHi + 0.5) {
3026
- e.labelPosition[cross] += delta
3027
- }
3028
- }
3029
-
3030
- const apply = (node: PositionedNode, delta: number, aligned: PositionedEdge): void => {
3031
- if (isHorizontal) node.y += delta
3032
- else node.x += delta
3033
- for (const e of edges) {
3034
- if (e === aligned || e.points.length === 0) continue
3035
- const srcTouch = e.source === node.id
3036
- const tgtTouch = e.target === node.id
3037
- if (srcTouch && tgtTouch) {
3038
- for (const p of e.points) p[cross] += delta
3039
- if (e.labelPosition) e.labelPosition[cross] += delta
3040
- continue
3041
- }
3042
- if (tgtTouch) shiftRun(e, true, delta)
3043
- if (srcTouch) shiftRun(e, false, delta)
3044
- }
3045
- const first = aligned.points[0]!
3046
- const last = aligned.points[aligned.points.length - 1]!
3047
- if (aligned.points.length === 2 && Math.abs(first[cross] - last[cross]) <= 0.5) {
3048
- // Already a straight lane: re-anchor both ends onto the EXACT ports
3049
- // via shapePorts — correct for every PORT_EXACT shape (a diamond's
3050
- // sloped facet and a circle's curve change the main anchor with the
3051
- // cross slide; bbox side midpoints are exact for all of them, and the
3052
- // downstream shape clipper preserves port-exact endpoints).
3053
- const exitSide = isHorizontal ? (sign > 0 ? 'E' : 'W') : (sign > 0 ? 'S' : 'N')
3054
- const entrySide = isHorizontal ? (sign > 0 ? 'W' : 'E') : (sign > 0 ? 'N' : 'S')
3055
- aligned.points = [
3056
- shapePorts(nodeMap.get(aligned.source)!)[exitSide as 'N' | 'E' | 'S' | 'W'],
3057
- shapePorts(nodeMap.get(aligned.target)!)[entrySide as 'N' | 'E' | 'S' | 'W'],
3058
- ]
3059
- if (aligned.labelPosition) aligned.labelPosition[cross] = portCross(nodeMap.get(aligned.source)!)
3060
- } else {
3061
- // A staircase: translate the moved end's terminal run and let the
3062
- // certifying straightener collapse it onto the (now shared) port lane
3063
- // with a proof — or keep it if the lane turns out blocked.
3064
- shiftRun(aligned, aligned.target === node.id, delta)
3065
- }
3066
- }
3067
-
3068
- // Forward edges run monotone along the flow axis; feedback loops and
3069
- // container routes double back. Only forward edges occupy flow-side
3070
- // facets, so only they count against a vertex's capacity.
3071
- const isForwardMonotone = (e: PositionedEdge): boolean => {
3072
- if (e.points.length < 2 || e.source === e.target) return false
3073
- const pts = simplifyPolyline(e.points)
3074
- for (let i = 1; i < pts.length; i++) {
3075
- if ((pts[i]![main] - pts[i - 1]![main]) * sign < -0.5) return false
3076
- }
3077
- return true
3078
- }
3079
- // ---- Active fan-in centering (vertical mirror symmetry) --------------
3080
- // A hub T fed by N unlabeled, equal-rank, forward sources should sit on
3081
- // the EXACT cross-axis barycenter of those sources so the merge renders
3082
- // mirror-symmetric. ELK's BALANCED placement leaves T ~20px off-barycenter
3083
- // and the per-edge slide below amplifies it; centering T once, up front,
3084
- // removes both errors and lets applyRouteContracts merge the now-symmetric
3085
- // incoming edges. Proof-gated by moveSafe against ONE representative
3086
- // incoming edge (every incoming edge shares T's port lane, so clearing the
3087
- // barycenter for one clears it for all). Hubs handled here are skipped by
3088
- // the slide loop (their incoming edges are frozen).
3089
- // The hub's translated bbox must keep clear of every other node, of every
3090
- // FOREIGN edge corridor (an edge not incident to the hub), and of every
3091
- // label pill — the same occlusion doctrine moveSafe enforces, restated for
3092
- // a node whose ALL incident edges move with it (so incident edges impose no
3093
- // run/hop constraints; only the new footprint does). Incoming-edge sources
3094
- // stay put and their lanes are re-merged by applyRouteContracts.
3095
- const hubMoveSafe = (node: PositionedNode, delta: number, incidentIds: ReadonlySet<string>): boolean => {
3096
- const nx = isHorizontal ? node.x : node.x + delta
3097
- const ny = isHorizontal ? node.y + delta : node.y
3098
- for (const other of nodes) {
3099
- if (other === node) continue
3100
- if (overlaps(nx - MARGIN, ny - MARGIN, node.width + 2 * MARGIN, node.height + 2 * MARGIN,
3101
- other.x, other.y, other.width, other.height)) return false
3102
- }
3103
- for (const e of edges) {
3104
- if (incidentIds.has(`${e.source}->${e.target}`)) continue
3105
- for (let i = 1; i < e.points.length; i++) {
3106
- const a = e.points[i - 1]!, b = e.points[i]!
3107
- if (Math.max(a.x, b.x) > nx + 0.5 && Math.min(a.x, b.x) < nx + node.width - 0.5 &&
3108
- Math.max(a.y, b.y) > ny + 0.5 && Math.min(a.y, b.y) < ny + node.height - 0.5) return false
3109
- }
3110
- }
3111
- for (const e of edges) {
3112
- if (incidentIds.has(`${e.source}->${e.target}`)) continue
3113
- const rect = labelRect(e, style)
3114
- if (rect && overlaps(nx - MARGIN, ny - MARGIN, node.width + 2 * MARGIN, node.height + 2 * MARGIN,
3115
- rect.x, rect.y, rect.w, rect.h)) return false
3116
- }
3117
- return true
3118
- }
3119
-
3120
- const centeredHubs = new Set<string>()
3121
- const forwardEdgesFrom = (id: string): PositionedEdge[] => edges.filter(e => e.source === id && isForwardMonotone(e))
3122
- const forwardIndegree = (id: string): number => edges.filter(e => e.target === id && isForwardMonotone(e)).length
3123
- const ownedForwardClosure = (root: PositionedNode): PositionedNode[] => {
3124
- const out: PositionedNode[] = []
3125
- const seen = new Set<string>()
3126
- const queue = [root]
3127
- while (queue.length > 0) {
3128
- const node = queue.shift()!
3129
- if (seen.has(node.id)) continue
3130
- seen.add(node.id)
3131
- out.push(node)
3132
- for (const edge of forwardEdgesFrom(node.id)) {
3133
- const next = nodeMap.get(edge.target)
3134
- if (!next || seen.has(next.id)) continue
3135
- if (forwardIndegree(next.id) === 1) queue.push(next)
3136
- }
3137
- }
3138
- return out
3139
- }
3140
- const chainMoveSafe = (chain: PositionedNode[], delta: number): boolean => {
3141
- const ids = new Set(chain.map(n => n.id))
3142
- for (const node of chain) {
3143
- const nx = isHorizontal ? node.x : node.x + delta
3144
- const ny = isHorizontal ? node.y + delta : node.y
3145
- for (const other of nodes) {
3146
- if (ids.has(other.id)) continue
3147
- if (overlaps(nx - MARGIN, ny - MARGIN, node.width + 2 * MARGIN, node.height + 2 * MARGIN,
3148
- other.x, other.y, other.width, other.height)) return false
3149
- }
3150
- }
3151
- return true
3152
- }
3153
- const moveChain = (chain: PositionedNode[], delta: number): void => {
3154
- const ids = new Set(chain.map(n => n.id))
3155
- for (const node of chain) {
3156
- if (isHorizontal) node.y += delta
3157
- else node.x += delta
3158
- }
3159
- for (const e of edges) {
3160
- const srcTouch = ids.has(e.source)
3161
- const tgtTouch = ids.has(e.target)
3162
- if (srcTouch && tgtTouch) {
3163
- for (const p of e.points) p[cross] += delta
3164
- if (e.labelPosition) e.labelPosition[cross] += delta
3165
- } else if (tgtTouch) {
3166
- shiftRun(e, true, delta, /* anchorFar */ true)
3167
- } else if (srcTouch) {
3168
- shiftRun(e, false, delta, /* anchorFar */ true)
3169
- }
3170
- }
3171
- }
3172
- for (const t of (layoutEnvFlag('APL_NO_CENTER') ? [] : nodes)) {
3173
- if (frozen.has(t.id) || inGroup(t) || !PORT_EXACT.has(t.shape)) continue
3174
- const incoming = edges.filter(e =>
3175
- e.target === t.id && e.source !== t.id && e.points.length >= 2)
3176
- // ≥2 DISTINCT sources, all unlabeled, all forward-monotone.
3177
- const sources = new Map<string, PositionedNode>()
3178
- let ok = incoming.length >= 2
3179
- for (const e of incoming) {
3180
- if (e.label || !isForwardMonotone(e)) { ok = false; break }
3181
- const s = nodeMap.get(e.source)
3182
- if (!s || !PORT_EXACT.has(s.shape)) { ok = false; break }
3183
- sources.set(s.id, s)
3184
- }
3185
- if (!ok || sources.size < 2) continue
3186
- // Hubs may own a single forward continuation: moving the hub improves the
3187
- // merge symmetry, and the later port-lane pass can keep that continuation
3188
- // straight. Multiple forward branches would make the centering ambiguous.
3189
- if (edges.filter(o => o.source === t.id && o.target !== t.id && isForwardMonotone(o)).length > 1) continue
3190
- // Equal rank: all sources stacked in ONE column — same main-axis band.
3191
- const srcList = [...sources.values()]
3192
- const mainCenters = srcList.map(s => s[main] + mainSize(s) / 2)
3193
- const mainSpan = Math.max(...mainCenters) - Math.min(...mainCenters)
3194
- const maxMainSize = Math.max(...srcList.map(mainSize))
3195
- if (mainSpan > maxMainSize + DEFAULTS.layerSpacing * 0.6) continue
3196
- // Barycenter of source port-cross-coords; the delta to move T onto it.
3197
- const bary = srcList.reduce((a, s) => a + portCross(s), 0) / srcList.length
3198
- const delta = bary - portCross(t)
3199
- if (Math.abs(delta) <= 0.5) { centeredHubs.add(t.id); continue }
3200
- const chain = ownedForwardClosure(t)
3201
- const incidentIds = new Set(edges
3202
- .filter(e => e.source === t.id || e.target === t.id)
3203
- .map(e => `${e.source}->${e.target}`))
3204
- if (!hubMoveSafe(t, delta, incidentIds) || !chainMoveSafe(chain, delta)) continue
3205
- layoutDebug('[apl] center hub', t.id, 'by', delta.toFixed(1), 'over', srcList.length, 'sources')
3206
- // Move the hub and any exclusively-owned forward continuation together.
3207
- // That preserves straight owned chains such as CCC→DDDD while still
3208
- // refusing to drag shared downstream merges like A/B→C.
3209
- moveChain(chain, delta)
3210
- for (const node of chain) {
3211
- centeredHubs.add(node.id)
3212
- frozen.add(node.id)
3213
- }
3214
- for (const e of incoming) frozen.add(e.source)
3215
- }
3216
-
3217
- // ---- Diamond facet-mid alignment (E / F) -----------------------------
3218
- // A diamond that emits exactly TWO forward edges on its single flow-side
3219
- // facet attaches them at the facet MIDPOINTS (NE/SE for an E exit; rotated
3220
- // per direction) and snaps each target onto that facet-mid's cross lane, so
3221
- // both edges become port-to-port STRAIGHT instead of floating at arbitrary
3222
- // facet positions. This is the natural extension of the cardinal port-lane
3223
- // slide to facet-mid lanes: the source's two designated facet points pair
3224
- // with the targets' facing cardinal ports. Proof-gated by moveSafe.
3225
- const facetAligned = new Set<PositionedEdge>()
3226
- if (!layoutEnvFlag('APL_NO_FACET')) {
3227
- // The facet pair adjoining the flow-exit side, ordered (upper, lower) by
3228
- // cross coordinate. cross='y' for LR/RL (E/W exit → NE/SE or NW/SW);
3229
- // cross='x' for TD/BT (S/N exit → SW/SE or NW/NE).
3230
- const facetPair = (): [DiamondFacet, DiamondFacet] => {
3231
- if (isHorizontal) return sign > 0 ? ['NE', 'SE'] : ['NW', 'SW']
3232
- return sign > 0 ? ['SW', 'SE'] : ['NW', 'NE'] // upper=smaller x first
3233
- }
3234
- for (const src of nodes) {
3235
- if (src.shape !== 'diamond' || frozen.has(src.id) || inGroup(src)) continue
3236
- // Exactly two forward edges, both leaving src for distinct PORT_EXACT
3237
- // targets, none labeled-feedback, and src emits nothing else forward.
3238
- const out = edges.filter(e => e.source === src.id && e.target !== src.id &&
3239
- e.points.length >= 2 && isForwardMonotone(e))
3240
- if (out.length !== 2) continue
3241
- if (out.some(e => { const t = nodeMap.get(e.target); return !t || !PORT_EXACT.has(t.shape) })) continue
3242
- if (out.some(e => frozen.has(e.target) || centeredHubs.has(e.target) || inGroup(nodeMap.get(e.target)!))) continue
3243
- // Order the two edges by their target's current cross position: the
3244
- // upper edge takes the upper facet, the lower takes the lower facet.
3245
- const ordered = [...out].sort((a, b) =>
3246
- portCross(nodeMap.get(a.target)!) - portCross(nodeMap.get(b.target)!))
3247
- const [upperFacet, lowerFacet] = facetPair()
3248
- const facets = diamondFacetPorts(src)
3249
- const assignment: Array<[PositionedEdge, DiamondFacet]> = [
3250
- [ordered[0]!, upperFacet], [ordered[1]!, lowerFacet],
3251
- ]
3252
- // A `lane` is the per-edge SOURCE attachment point (the facet-mid by
3253
- // default) and the cross coordinate the target column snaps onto. A lane
3254
- // set is committed only if BOTH gates pass: every target slides onto its
3255
- // lane safely (moveSafe — proves each slide independently), AND the two
3256
- // FINAL target boxes clear each other (the targets move toward each other
3257
- // in vertical flow as the facet-mids collapse their columns, so moveSafe
3258
- // alone can't see the pairwise collision). The cardinal-vertex fallback
3259
- // below only swaps the source points; everything downstream is
3260
- // lane-driven, so both gates run uniformly over whichever set we pick.
3261
- const srcPorts = shapePorts(src)
3262
- type Lane = { edge: PositionedEdge; src: Point; cross: number }
3263
- const facetLanes: Lane[] = assignment.map(([e, facet]) => (
3264
- { edge: e, src: { x: facets[facet].x, y: facets[facet].y }, cross: facets[facet][cross] }))
3265
- // For a VERTICAL-flow (TD/BT) diamond the South/North facet-mids are only
3266
- // w/2 apart, so HORIZONTALLY-stacked targets wider than that overlap when
3267
- // snapped together (E bails for this reason). The diamond's E and W
3268
- // cardinal VERTICES are the full width w apart — much wider — so the same
3269
- // targets fit. Route the upper-cross edge out W, the lower out E (the
3270
- // upper target snaps to x=W.x, the lower to x=E.x), keeping both edges
3271
- // port-to-port straight (sourcePort W/E, targetPort N for TD). Mirrored
3272
- // for BT by the entrySide = N/S choice. Horizontal flow keeps the facets.
3273
- const cardinalLanes: Lane[] | null = (!isHorizontal && assignment[0] && assignment[1])
3274
- ? [
3275
- { edge: assignment[0]![0], src: { x: srcPorts.W.x, y: srcPorts.W.y }, cross: srcPorts.W[cross] },
3276
- { edge: assignment[1]![0], src: { x: srcPorts.E.x, y: srcPorts.E.y }, cross: srcPorts.E[cross] },
3277
- ]
3278
- : null
3279
-
3280
- // Targets snapped onto a lane set must not collide with each other.
3281
- const pairOverlaps = (lanes: Lane[]): boolean => {
3282
- const boxes = lanes.map(l => {
3283
- const t = nodeMap.get(l.edge.target)!
3284
- const delta = l.cross - portCross(t)
3285
- return {
3286
- x: isHorizontal ? t.x : t.x + delta, y: isHorizontal ? t.y + delta : t.y,
3287
- w: t.width, h: t.height,
3288
- }
3289
- })
3290
- const [a, b] = boxes
3291
- return !!(a && b && overlaps(a.x - MARGIN, a.y - MARGIN, a.w + 2 * MARGIN, a.h + 2 * MARGIN, b.x, b.y, b.w, b.h))
3292
- }
3293
- // Each target must slide onto its lane's cross coordinate (moveSafe) and
3294
- // the resulting lane must clear — recheck for whichever lane set we use.
3295
- const slidesSafe = (lanes: Lane[]): boolean => lanes.every(l => {
3296
- const t = nodeMap.get(l.edge.target)!
3297
- const delta = l.cross - portCross(t)
3298
- return Math.abs(delta) <= 0.5 || moveSafe(t, delta, l.edge)
3299
- })
3300
-
3301
- // Prefer the facet-mids; for vertical flow fall back to the wider E/W
3302
- // cardinal vertices when the facets would collapse the targets into a
3303
- // collision (or fail to slide). If neither set is usable, leave src alone.
3304
- const usable = (lanes: Lane[] | null): boolean =>
3305
- !!lanes && slidesSafe(lanes) && !pairOverlaps(lanes)
3306
- let lanes: Lane[]
3307
- if (usable(facetLanes)) lanes = facetLanes
3308
- else if (usable(cardinalLanes)) lanes = cardinalLanes!
3309
- else continue
3310
- const entrySide = isHorizontal ? (sign > 0 ? 'W' : 'E') : (sign > 0 ? 'N' : 'S')
3311
- for (const l of lanes) {
3312
- const e = l.edge
3313
- const tgt = nodeMap.get(e.target)!
3314
- const delta = l.cross - portCross(tgt)
3315
- if (Math.abs(delta) > 0.5) {
3316
- if (isHorizontal) tgt.y += delta; else tgt.x += delta
3317
- for (const o of edges) {
3318
- if (o === e) continue
3319
- const sT = o.source === tgt.id, tT = o.target === tgt.id
3320
- if (sT && tT) { for (const p of o.points) p[cross] += delta; if (o.labelPosition) o.labelPosition[cross] += delta }
3321
- else if (tT) shiftRun(o, true, delta)
3322
- else if (sT) shiftRun(o, false, delta)
3323
- }
3324
- }
3325
- // Re-anchor: source on its facet-mid OR cardinal vertex (now collinear
3326
- // with the target's facing cardinal port), target on that cardinal port.
3327
- e.points = [
3328
- { x: l.src.x, y: l.src.y },
3329
- shapePorts(tgt)[entrySide as 'N' | 'E' | 'S' | 'W'],
3330
- ]
3331
- if (e.labelPosition) e.labelPosition[cross] = l.cross
3332
- facetAligned.add(e)
3333
- frozen.add(e.target)
3334
- }
3335
- frozen.add(src.id)
3336
- }
3337
- }
3338
-
3339
- // ---- Diamond side-input → perpendicular vertex (K) -------------------
3340
- // A single forward edge whose source sits fully to one CROSS side of a
3341
- // diamond target — below it for LR — when the target's facing cardinal port
3342
- // (W for LR) is already taken by the labelled main branch, routes into the
3343
- // diamond's perpendicular vertex (the S vertex when the source is below)
3344
- // instead of floating on the facet. Proof-gated: the source slides onto the
3345
- // vertex's cross lane (moveSafe) and the resulting straight lane must clear.
3346
- if (!layoutEnvFlag('APL_NO_SVERTEX')) {
3347
- for (const e of edges) {
3348
- if (facetAligned.has(e) || e.points.length < 2 || e.source === e.target) continue
3349
- const src = nodeMap.get(e.source)
3350
- const tgt = nodeMap.get(e.target)
3351
- if (!src || !tgt || tgt.shape !== 'diamond') continue
3352
- if (frozen.has(src.id) || frozen.has(tgt.id) || inGroup(src) || inGroup(tgt)) continue
3353
- if (!isForwardMonotone(e) || e.label) continue
3354
- // The facing cardinal entry port must be CLAIMED by another forward edge
3355
- // (the labelled main branch lands there), forcing this side input onto
3356
- // a different designated point.
3357
- // Another forward edge into this diamond owns the facing cardinal port:
3358
- // its source sits roughly ON the diamond's flow-axis centerline (so it
3359
- // claims the W/E/N/S midpoint), unlike this off-cross side input. The
3360
- // re-anchor onto that exact port happens later in the pipeline; here we
3361
- // detect the contention by the source's cross alignment.
3362
- const tgtCross0 = tgt[cross] + crossSize(tgt) / 2
3363
- const entryTaken = edges.some(o => {
3364
- if (o === e || o.target !== tgt.id || !isForwardMonotone(o) || o.points.length < 2) return false
3365
- const os = nodeMap.get(o.source)
3366
- if (!os) return false
3367
- return Math.abs(os[cross] + crossSize(os) / 2 - tgtCross0) <= crossSize(tgt) / 2
3368
- })
3369
- if (!entryTaken) continue
3370
- // Source must sit on ONE cross side of the diamond and below/above its
3371
- // body: its center is beyond the diamond's center on the cross axis, and
3372
- // it clears the diamond's cross extent enough to face the vertex.
3373
- const tgtCenterCross = tgt[cross] + crossSize(tgt) / 2
3374
- const srcCenterCross = src[cross] + crossSize(src) / 2
3375
- const below = srcCenterCross > tgtCenterCross
3376
- // The perpendicular vertex toward the source (S for LR-below, N above).
3377
- const vertexSide = isHorizontal ? (below ? 'S' : 'N') : (below ? 'E' : 'W')
3378
- const vertex = shapePorts(tgt)[vertexSide as 'N' | 'E' | 'S' | 'W']
3379
- // The source must be far enough onto that side that a straight lane at
3380
- // the vertex's cross level approaches it from the flow direction — i.e.
3381
- // the source center is past the diamond's facing-entry cross extent.
3382
- const facingHalf = crossSize(tgt) / 2
3383
- if (Math.abs(srcCenterCross - tgtCenterCross) < facingHalf * 0.5) continue
3384
- const delta = vertex[cross] - portCross(src)
3385
- if (Math.abs(delta) > 0.5 && !moveSafe(src, delta, e)) continue
3386
- // The straight lane from the source's facing port to the vertex must be
3387
- // clear and run forward.
3388
- const srcExit = isHorizontal ? (sign > 0 ? 'E' : 'W') : (sign > 0 ? 'S' : 'N')
3389
- const exitPort = shapePorts(src)[srcExit as 'N' | 'E' | 'S' | 'W']
3390
- const laneLo = Math.min(exitPort[main], vertex[main])
3391
- const laneHi = Math.max(exitPort[main], vertex[main])
3392
- if ((vertex[main] - exitPort[main]) * sign <= 0.5) continue
3393
- if (laneBlocked(vertex[cross], laneLo, laneHi, new Set([src.id, tgt.id]))) continue
3394
- layoutDebug('[apl] svertex', e.source, '->', e.target, 'into', vertexSide, 'vertex')
3395
- if (Math.abs(delta) > 0.5) {
3396
- if (isHorizontal) src.y += delta; else src.x += delta
3397
- for (const o of edges) {
3398
- if (o === e) continue
3399
- const sT = o.source === src.id, tT = o.target === src.id
3400
- if (sT && tT) { for (const p of o.points) p[cross] += delta; if (o.labelPosition) o.labelPosition[cross] += delta }
3401
- else if (tT) shiftRun(o, true, delta)
3402
- else if (sT) shiftRun(o, false, delta)
3403
- }
3404
- }
3405
- e.points = [
3406
- { x: shapePorts(src)[srcExit as 'N' | 'E' | 'S' | 'W'].x, y: shapePorts(src)[srcExit as 'N' | 'E' | 'S' | 'W'].y },
3407
- { x: vertex.x, y: vertex.y },
3408
- ]
3409
- facetAligned.add(e)
3410
- frozen.add(src.id)
3411
- frozen.add(tgt.id)
3412
- }
3413
- }
3414
-
3415
- for (const e of edges) {
3416
- if (facetAligned.has(e)) continue
3417
- if (e.points.length < 2 || e.source === e.target) continue
3418
- const src = nodeMap.get(e.source)
3419
- const tgt = nodeMap.get(e.target)
3420
- if (!src || !tgt) continue
3421
- if (!PORT_EXACT.has(src.shape) || !PORT_EXACT.has(tgt.shape)) continue
3422
- // A centered fan-in hub is symmetric by construction; the slide would
3423
- // re-break it by pulling the hub onto a single source's lane.
3424
- if (centeredHubs.has(tgt.id)) continue
3425
- // A diamond's vertex has capacity 1 (the yFiles port-candidate cost
3426
- // model): a source diamond with a FORWARD fan-out must SPREAD its lines
3427
- // on the facet, so aligning one target onto the vertex lane is wrong
3428
- // there. A feedback sibling does not count: it leaves via the outer
3429
- // channel and never occupies the flow-side facet.
3430
- if (src.shape === 'diamond' &&
3431
- edges.some(o => o !== e && o.source === e.source && isForwardMonotone(o))) continue
3432
- const d = portCross(src) - portCross(tgt)
3433
- if (Math.abs(d) <= 0.5) continue
3434
- // Primary-forward shape: a monotone staircase along the flow axis (a
3435
- // feedback loop or container route must not be retargeted at a port).
3436
- if (!isForwardMonotone(e) || simplifyPolyline(e.points).length > 4) continue
3437
- const moved = moveSafe(tgt, d, e) ? tgt : moveSafe(src, -d, e) ? src : null
3438
- if (moved) {
3439
- layoutDebug('[apl] slide', moved.id, 'by', (moved === tgt ? d : -d).toFixed(1), 'for', e.source, '->', e.target)
3440
- apply(moved, moved === tgt ? d : -d, e)
3441
- frozen.add(e.source)
3442
- frozen.add(e.target)
3443
- }
3444
- }
3445
- }
3446
-
3447
- /**
3448
- * ELK's orthogonal edge routing staggers nodes within the same layer to create
3449
- * space for edge bends. This post-processing step groups nodes into layers and
3450
- * snaps them to the same flow-axis coordinate (Y for TD/TB, X for LR/RL).
3451
- *
3452
- * Grouping uses proximity along the flow axis: within a layer, ELK's stagger
3453
- * is always less than layerSpacing (bounded by edge routing channels), while
3454
- * adjacent layers are separated by at least layerSpacing + nodeHeight.
3455
- * A threshold of 0.75 * layerSpacing cleanly separates these cases.
3456
- *
3457
- * Directly connected nodes (sharing an edge) are never merged into the same
3458
- * layer group as an additional safety check.
3459
- *
3460
- * Edge endpoints connected to shifted nodes are adjusted proportionally.
3461
- * Intermediate bend points are left unchanged — edge bundling or clipping
3462
- * will recalculate them afterwards.
3463
- */
3464
- export function alignLayerNodes(
3465
- nodes: PositionedNode[],
3466
- edges: PositionedEdge[],
3467
- direction: Direction
3468
- ): void {
3469
- if (nodes.length === 0) return
3470
-
3471
- const isHorizontal = direction === 'LR' || direction === 'RL'
3472
-
3473
- // Build set of directly-connected node pairs.
3474
- // Nodes connected by an edge must not be merged into the same layer.
3475
- const connectedPairs = new Set<string>()
3476
- for (const edge of edges) {
3477
- connectedPairs.add(`${edge.source}:${edge.target}`)
3478
- connectedPairs.add(`${edge.target}:${edge.source}`)
3479
- }
3480
-
3481
- // ELK's stagger creates small gaps between adjacent nodes in the same layer
3482
- // (typically edgeEdge spacing = 12px per routing channel). Adjacent layers
3483
- // are separated by at least layerSpacing (48px). We use single-linkage
3484
- // clustering: a node joins the current layer if the gap from the previous
3485
- // node (in sorted order) is within threshold, AND it has no direct edge to
3486
- // any node already in the layer.
3487
- const THRESHOLD = DEFAULTS.layerSpacing * 0.6
3488
-
3489
- // Sort nodes by flow-axis position
3490
- const sorted = [...nodes].sort((a, b) =>
3491
- isHorizontal ? a.x - b.x : a.y - b.y
3492
- )
3493
-
3494
- const layers: PositionedNode[][] = []
3495
- let currentLayer: PositionedNode[] = [sorted[0]!]
3496
-
3497
- for (let i = 1; i < sorted.length; i++) {
3498
- const pos = isHorizontal ? sorted[i]!.x : sorted[i]!.y
3499
- const prevPos = isHorizontal ? sorted[i - 1]!.x : sorted[i - 1]!.y
3500
- // Single-linkage: compare with previous node, not layer start
3501
- const gap = pos - prevPos
3502
- // Check if this node is connected to any node already in the current layer
3503
- const hasEdgeToLayer = currentLayer.some(n =>
3504
- connectedPairs.has(`${n.id}:${sorted[i]!.id}`)
3505
- )
3506
- if (gap <= THRESHOLD && !hasEdgeToLayer) {
3507
- currentLayer.push(sorted[i]!)
3508
- } else {
3509
- layers.push(currentLayer)
3510
- currentLayer = [sorted[i]!]
3511
- }
3512
- }
3513
- layers.push(currentLayer)
3514
-
3515
- // Snap each layer's nodes to the layer's center position
3516
- const deltas = new Map<string, number>() // nodeId → shift amount
3517
-
3518
- // A snapped node must not land on an already-routed corridor: edges were
3519
- // routed against the PRE-snap positions, and a node moved onto a foreign
3520
- // edge's path would occlude it (issue #25 rule 9 — no node movement after
3521
- // routing without rerouting). Alignment is cosmetic; occlusion is a hard
3522
- // defect, so a layer whose snap would cause one keeps ELK's stagger.
3523
- const snapOccludes = (node: PositionedNode, newPos: number): boolean => {
3524
- const nx = isHorizontal ? newPos : node.x
3525
- const ny = isHorizontal ? node.y : newPos
3526
- for (const edge of edges) {
3527
- if (edge.source === node.id || edge.target === node.id) continue
3528
- for (let i = 1; i < edge.points.length; i++) {
3529
- const a = edge.points[i - 1]!, b = edge.points[i]!
3530
- const xLo = Math.min(a.x, b.x), xHi = Math.max(a.x, b.x)
3531
- const yLo = Math.min(a.y, b.y), yHi = Math.max(a.y, b.y)
3532
- if (xHi > nx + 0.5 && xLo < nx + node.width - 0.5 &&
3533
- yHi > ny + 0.5 && yLo < ny + node.height - 0.5) {
3534
- return true
3535
- }
3536
- }
3537
- }
3538
- return false
3539
- }
3540
-
3541
- for (const layer of layers) {
3542
- if (layer.length <= 1) continue
3543
-
3544
- const positions = layer.map(n => isHorizontal ? n.x : n.y)
3545
- const min = Math.min(...positions)
3546
- const max = Math.max(...positions)
3547
- if (max - min <= 1) continue // Already aligned
3548
-
3549
- // Use the center of the range as the snap target
3550
- const target = (min + max) / 2
3551
-
3552
- if (layer.some(node => {
3553
- const oldPos = isHorizontal ? node.x : node.y
3554
- return Math.abs(target - oldPos) > 0.5 && snapOccludes(node, target)
3555
- })) continue
3556
-
3557
- for (const node of layer) {
3558
- const oldPos = isHorizontal ? node.x : node.y
3559
- const delta = target - oldPos
3560
- if (Math.abs(delta) > 0.5) {
3561
- if (isHorizontal) {
3562
- node.x = target
3563
- } else {
3564
- node.y = target
3565
- }
3566
- deltas.set(node.id, delta)
3567
- }
3568
- }
3569
- }
3570
-
3571
- if (deltas.size === 0) return
3572
-
3573
- // Build node lookup for edge adjustment
3574
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
3575
-
3576
- // Adjust edge endpoints to match shifted node positions
3577
- for (const edge of edges) {
3578
- if (edge.points.length < 2) continue
3579
-
3580
- const srcDelta = deltas.get(edge.source)
3581
- const tgtDelta = deltas.get(edge.target)
3582
-
3583
- if (srcDelta != null) {
3584
- // Shift first point and any subsequent points in the initial vertical/horizontal run
3585
- const first = edge.points[0]!
3586
- if (isHorizontal) {
3587
- first.x += srcDelta
3588
- // Shift second point if it's part of a straight vertical exit
3589
- if (edge.points.length > 1 && edge.points[1]!.x === first.x - srcDelta) {
3590
- edge.points[1]!.x += srcDelta
3591
- }
3592
- } else {
3593
- first.y += srcDelta
3594
- if (edge.points.length > 1 && edge.points[1]!.y === first.y - srcDelta) {
3595
- edge.points[1]!.y += srcDelta
3596
- }
3597
- }
3598
- }
3599
-
3600
- if (tgtDelta != null) {
3601
- const last = edge.points[edge.points.length - 1]!
3602
- if (isHorizontal) {
3603
- last.x += tgtDelta
3604
- if (edge.points.length > 1) {
3605
- const prev = edge.points[edge.points.length - 2]!
3606
- if (prev.x === last.x - tgtDelta) prev.x += tgtDelta
3607
- }
3608
- } else {
3609
- last.y += tgtDelta
3610
- if (edge.points.length > 1) {
3611
- const prev = edge.points[edge.points.length - 2]!
3612
- if (prev.y === last.y - tgtDelta) prev.y += tgtDelta
3613
- }
3614
- }
3615
- }
3616
- }
3617
- }
3618
-
3619
- /**
3620
- * Find all groups (outermost first) that geometrically contain the given point.
3621
- */
3622
- function findGroupsContainingPoint(
3623
- x: number, y: number,
3624
- groups: PositionedGroup[]
3625
- ): PositionedGroup[] {
3626
- const result: PositionedGroup[] = []
3627
- for (const g of groups) {
3628
- if (x >= g.x && x <= g.x + g.width && y >= g.y && y <= g.y + g.height) {
3629
- result.push(g)
3630
- result.push(...findGroupsContainingPoint(x, y, g.children))
3631
- }
3632
- }
3633
- return result
3634
- }
3635
-
3636
- /**
3637
- * Bundle contract (docs/design/system/route-contracts.md): a rebuilt trunk/branch
3638
- * path may not pass through any node other than the edge's own endpoints.
3639
- * The half-pixel tolerance lets a path graze a border without counting.
3640
- */
3641
- function bundlePathClear(
3642
- points: Point[],
3643
- nodes: PositionedNode[],
3644
- sourceId: string,
3645
- targetId: string,
3646
- ): boolean {
3647
- for (const n of nodes) {
3648
- if (n.id === sourceId || n.id === targetId) continue
3649
- for (let i = 1; i < points.length; i++) {
3650
- const a = points[i - 1]!, b = points[i]!
3651
- const xLo = Math.min(a.x, b.x), xHi = Math.max(a.x, b.x)
3652
- const yLo = Math.min(a.y, b.y), yHi = Math.max(a.y, b.y)
3653
- if (xHi > n.x + 0.5 && xLo < n.x + n.width - 0.5 && yHi > n.y + 0.5 && yLo < n.y + n.height - 0.5) {
3654
- return false
3655
- }
3656
- }
3657
- }
3658
- return true
3659
- }
3660
-
3661
- /**
3662
- * If `junction` falls inside a group that doesn't contain the reference node,
3663
- * move it just outside the outermost such group boundary.
3664
- */
3665
- function adjustJunctionForGroups(
3666
- junctionMain: number, // the junction coordinate along the flow axis (Y for TD, X for LR)
3667
- refX: number, // reference node center X (for finding its groups)
3668
- refY: number, // reference node center Y
3669
- groups: PositionedGroup[],
3670
- direction: Direction
3671
- ): number {
3672
- const GAP = 12
3673
- const isLR = direction === 'LR'
3674
- const isRL = direction === 'RL'
3675
- const isBT = direction === 'BT'
3676
- const isHorizontal = isLR || isRL
3677
-
3678
- // Groups containing the reference node
3679
- const refGroupIds = new Set(findGroupsContainingPoint(refX, refY, groups).map(g => g.id))
3680
-
3681
- // Check where the junction point would be along the trunk
3682
- const probeX = isHorizontal ? junctionMain : refX
3683
- const probeY = isHorizontal ? refY : junctionMain
3684
- const junctionGroups = findGroupsContainingPoint(probeX, probeY, groups)
3685
-
3686
- // Find outermost group containing the junction but NOT the reference node
3687
- const crossingGroup = junctionGroups.find(g => !refGroupIds.has(g.id))
3688
- if (!crossingGroup) return junctionMain
3689
-
3690
- // Move junction just outside this group
3691
- if (isLR) return crossingGroup.x - GAP
3692
- if (isRL) return crossingGroup.x + crossingGroup.width + GAP
3693
- if (isBT) return crossingGroup.y + crossingGroup.height + GAP
3694
- return crossingGroup.y - GAP // TD
3695
- }
3696
-
3697
- /**
3698
- * Bundle fan-out and fan-in edge paths so they share a common trunk segment.
3699
- *
3700
- * For fan-out (one source → N targets), all edges exit the source at the same
3701
- * point, travel along a shared trunk, then branch to their individual targets.
3702
- * The overlapping trunk segments render as a single visible line.
3703
- *
3704
- * Junction points are placed outside subgraph boundaries so branches split
3705
- * before entering a group, not inside it.
3706
- *
3707
- * Constraints: edges in a bundle must share the same style and have no labels.
3708
- * Self-loops and backward edges (against the graph direction) are excluded.
3709
- *
3710
- * Returns the set of edges whose paths the bundler owns, so the route-contract
3711
- * pass never straightens a trunk-shared path.
3712
- */
3713
- export function bundleEdgePaths(
3714
- edges: PositionedEdge[],
3715
- nodes: PositionedNode[],
3716
- groups: PositionedGroup[],
3717
- direction: Direction
3718
- ): Set<PositionedEdge> {
3719
- const nodeMap = new Map(nodes.map(n => [n.id, n]))
3720
- const processed = new Set<PositionedEdge>()
3721
-
3722
- const isLR = direction === 'LR'
3723
- const isRL = direction === 'RL'
3724
- const isBT = direction === 'BT'
3725
- const isHorizontal = isLR || isRL
3726
-
3727
- // --- Fan-out: group edges by shared source ---
3728
- const fanOutGroups = new Map<string, PositionedEdge[]>()
3729
- for (const edge of edges) {
3730
- if (edge.source === edge.target) continue
3731
- if (!fanOutGroups.has(edge.source)) fanOutGroups.set(edge.source, [])
3732
- fanOutGroups.get(edge.source)!.push(edge)
3733
- }
3734
-
3735
- for (const [sourceId, group] of fanOutGroups) {
3736
- if (group.length < 2) continue
3737
-
3738
- const style = group[0]!.style
3739
- if (group.some(e => e.label || e.style !== style)) continue
3740
-
3741
- const source = nodeMap.get(sourceId)
3742
- if (!source) continue
3743
-
3744
- // Only bundle edges going in the forward direction
3745
- const forward = group.filter(e => {
3746
- const t = nodeMap.get(e.target)
3747
- if (!t) return false
3748
- if (isLR) return t.x > source.x + source.width
3749
- if (isRL) return t.x + t.width < source.x
3750
- if (isBT) return t.y + t.height < source.y
3751
- return t.y > source.y + source.height // TD/TB
3752
- })
3753
- if (forward.length < 2) continue
3754
- // A fan needs >= 2 distinct targets: duplicate edges to one target would
3755
- // bundle onto byte-identical overlapping paths, hiding one of them.
3756
- if (new Set(forward.map(e => e.target)).size < 2) continue
3757
-
3758
- const srcCX = source.x + source.width / 2
3759
- const srcCY = source.y + source.height / 2
3760
-
3761
- // Bundle contract: rebuild candidate paths, drop any member whose path
3762
- // would pass through a node (it keeps its ELK route and the route-contract
3763
- // pass certifies it), and re-derive the junction from the members that
3764
- // remain — until the bundle is clear or too small to exist.
3765
- let members = forward.map(e => ({ edge: e, node: nodeMap.get(e.target)! }))
3766
- while (members.length >= 2) {
3767
- const candidates = new Map<PositionedEdge, Point[]>()
3768
- if (isHorizontal) {
3769
- const exitX = isLR ? source.x + source.width : source.x
3770
- const exitY = srcCY
3771
-
3772
- const nearestX = isLR
3773
- ? Math.min(...members.map(t => t.node.x))
3774
- : Math.max(...members.map(t => t.node.x + t.node.width))
3775
- let junctionX = exitX + (nearestX - exitX) / 2
3776
- junctionX = adjustJunctionForGroups(junctionX, srcCX, srcCY, groups, direction)
3777
-
3778
- for (const { edge, node: target } of members) {
3779
- const entryX = isLR ? target.x : target.x + target.width
3780
- const entryY = target.y + target.height / 2
3781
- candidates.set(edge, [
3782
- { x: exitX, y: exitY },
3783
- { x: junctionX, y: exitY },
3784
- { x: junctionX, y: entryY },
3785
- { x: entryX, y: entryY },
3786
- ])
3787
- }
3788
- } else {
3789
- const exitX = srcCX
3790
- const exitY = isBT ? source.y : source.y + source.height
3791
-
3792
- const nearestY = isBT
3793
- ? Math.max(...members.map(t => t.node.y + t.node.height))
3794
- : Math.min(...members.map(t => t.node.y))
3795
- let junctionY = exitY + (nearestY - exitY) / 2
3796
- junctionY = adjustJunctionForGroups(junctionY, srcCX, srcCY, groups, direction)
3797
-
3798
- for (const { edge, node: target } of members) {
3799
- const entryX = target.x + target.width / 2
3800
- const entryY = isBT ? target.y + target.height : target.y
3801
- candidates.set(edge, [
3802
- { x: exitX, y: exitY },
3803
- { x: exitX, y: junctionY },
3804
- { x: entryX, y: junctionY },
3805
- { x: entryX, y: entryY },
3806
- ])
3807
- }
3808
- }
3809
-
3810
- const clear = members.filter(m => bundlePathClear(candidates.get(m.edge)!, nodes, m.edge.source, m.edge.target))
3811
- if (clear.length === members.length) {
3812
- for (const { edge } of members) {
3813
- edge.points = candidates.get(edge)!
3814
- processed.add(edge)
3815
- }
3816
- break
3817
- }
3818
- members = clear
3819
- if (new Set(members.map(m => m.edge.target)).size < 2) break
3820
- }
3821
- }
3822
-
3823
- // --- Fan-in: group edges by shared target (skip already-bundled edges) ---
3824
- const fanInGroups = new Map<string, PositionedEdge[]>()
3825
- for (const edge of edges) {
3826
- if (processed.has(edge) || edge.source === edge.target) continue
3827
- if (!fanInGroups.has(edge.target)) fanInGroups.set(edge.target, [])
3828
- fanInGroups.get(edge.target)!.push(edge)
3829
- }
3830
-
3831
- for (const [targetId, group] of fanInGroups) {
3832
- if (group.length < 2) continue
3833
-
3834
- const style = group[0]!.style
3835
- if (group.some(e => e.label || e.style !== style)) continue
3836
-
3837
- const target = nodeMap.get(targetId)
3838
- if (!target) continue
3839
-
3840
- const forward = group.filter(e => {
3841
- const s = nodeMap.get(e.source)
3842
- if (!s) return false
3843
- if (isLR) return s.x + s.width < target.x
3844
- if (isRL) return s.x > target.x + target.width
3845
- if (isBT) return s.y > target.y + target.height
3846
- return s.y + s.height < target.y // TD/TB
3847
- })
3848
- if (forward.length < 2) continue
3849
- // Same distinctness rule as fan-out, for duplicate edges from one source.
3850
- if (new Set(forward.map(e => e.source)).size < 2) continue
3851
-
3852
- const tgtCX = target.x + target.width / 2
3853
- const tgtCY = target.y + target.height / 2
3854
-
3855
- // Same bundle contract as fan-out: shrink the bundle until every rebuilt
3856
- // path proves clear of other nodes.
3857
- let members = forward.map(e => ({ edge: e, node: nodeMap.get(e.source)! }))
3858
- while (members.length >= 2) {
3859
- const candidates = new Map<PositionedEdge, Point[]>()
3860
- if (isHorizontal) {
3861
- const entryX = isLR ? target.x : target.x + target.width
3862
- const entryY = tgtCY
3863
-
3864
- const farthestX = isLR
3865
- ? Math.max(...members.map(s => s.node.x + s.node.width))
3866
- : Math.min(...members.map(s => s.node.x))
3867
- let junctionX = farthestX + (entryX - farthestX) / 2
3868
- junctionX = adjustJunctionForGroups(junctionX, tgtCX, tgtCY, groups, direction)
3869
-
3870
- for (const { edge, node: src } of members) {
3871
- const exitX = isLR ? src.x + src.width : src.x
3872
- const exitY = src.y + src.height / 2
3873
- candidates.set(edge, [
3874
- { x: exitX, y: exitY },
3875
- { x: junctionX, y: exitY },
3876
- { x: junctionX, y: entryY },
3877
- { x: entryX, y: entryY },
3878
- ])
3879
- }
3880
- } else {
3881
- const entryX = tgtCX
3882
- const entryY = isBT ? target.y + target.height : target.y
3883
-
3884
- const farthestY = isBT
3885
- ? Math.min(...members.map(s => s.node.y))
3886
- : Math.max(...members.map(s => s.node.y + s.node.height))
3887
- let junctionY = farthestY + (entryY - farthestY) / 2
3888
- junctionY = adjustJunctionForGroups(junctionY, tgtCX, tgtCY, groups, direction)
3889
-
3890
- for (const { edge, node: src } of members) {
3891
- const exitX = src.x + src.width / 2
3892
- const exitY = isBT ? src.y : src.y + src.height
3893
- candidates.set(edge, [
3894
- { x: exitX, y: exitY },
3895
- { x: exitX, y: junctionY },
3896
- { x: entryX, y: junctionY },
3897
- { x: entryX, y: entryY },
3898
- ])
3899
- }
3900
- }
3901
-
3902
- const clear = members.filter(m => bundlePathClear(candidates.get(m.edge)!, nodes, m.edge.source, m.edge.target))
3903
- if (clear.length === members.length) {
3904
- for (const { edge } of members) {
3905
- edge.points = candidates.get(edge)!
3906
- processed.add(edge)
3907
- }
3908
- break
3909
- }
3910
- members = clear
3911
- if (new Set(members.map(m => m.edge.source)).size < 2) break
3912
- }
3913
- }
3914
-
3915
- return processed
3916
- }