agentic-mermaid 0.1.0 → 0.1.2

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 (333) hide show
  1. package/CHANGELOG.md +149 -4
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +25 -32
  4. package/THIRD_PARTY_NOTICES.md +29 -0
  5. package/assets/fonts/FONT-LICENSES.md +9 -5
  6. package/assets/fonts/Inter-Bold.ttf +0 -0
  7. package/assets/fonts/Inter-Medium.ttf +0 -0
  8. package/assets/fonts/Inter-Regular.ttf +0 -0
  9. package/assets/fonts/Inter-SemiBold.ttf +0 -0
  10. package/dist/agent-core.d.ts +463 -0
  11. package/dist/agent-core.js +397 -0
  12. package/dist/agent.d.ts +81 -1822
  13. package/dist/agent.js +336 -36839
  14. package/dist/agentic-mermaid-mcp.js +7 -37908
  15. package/dist/am.js +33638 -36261
  16. package/dist/chunk-4QECACIA.js +4702 -0
  17. package/dist/chunk-CDPKJ2BQ.js +37 -0
  18. package/dist/chunk-QKDLMOJJ.js +3487 -0
  19. package/dist/chunk-WI35L7IR.js +615 -0
  20. package/dist/chunk-WN3YDOBG.js +149 -0
  21. package/dist/chunk-ZYMMKQ3I.js +62637 -0
  22. package/dist/index-D54dqSdC.d.ts +6777 -0
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +310 -32836
  25. package/dist/mcp-cli-SBQSTJFC.js +17 -0
  26. package/docs/README.md +14 -6
  27. package/docs/schemas/style-spec.schema.json +1221 -30
  28. package/examples/styles/catalog.json +54 -0
  29. package/examples/styles/cloudflare-workers-inspired-prototype.mmd +9 -0
  30. package/examples/styles/cloudflare-workers-inspired-prototype.style.json +110 -0
  31. package/examples/styles/cupertino-prototype.mmd +10 -0
  32. package/examples/styles/cupertino-prototype.style.json +63 -0
  33. package/examples/styles/mid-century-report.style.json +2 -2
  34. package/examples/styles/star-chart-atlas.style.json +2 -1
  35. package/examples/styles/transit-route-map.style.json +2 -2
  36. package/examples/styles/vercel-inspired-prototype.mmd +6 -0
  37. package/examples/styles/vercel-inspired-prototype.style.json +87 -0
  38. package/package.json +70 -43
  39. package/server.json +41 -0
  40. package/skills/agentic-mermaid-diagram-workflow/SKILL.md +17 -36
  41. package/skills/agentic-mermaid-diagram-workflow/references/cli.md +4 -5
  42. package/skills/agentic-mermaid-diagram-workflow/references/code-mode.md +15 -31
  43. package/skills/agentic-mermaid-diagram-workflow/references/sequence.md +10 -14
  44. package/skills/agentic-mermaid-live-editor/SKILL.md +100 -206
  45. package/AGENT_NATIVE.md +0 -602
  46. package/Instructions_for_agents.md +0 -83
  47. package/TODO.md +0 -662
  48. package/assets/hero.png +0 -0
  49. package/dist/index-x2Cj1dVv.d.ts +0 -1089
  50. package/docs/agent-api-cookbook.md +0 -308
  51. package/docs/agent-mutation-policy.md +0 -104
  52. package/docs/agent-workflow-examples.md +0 -46
  53. package/docs/api.md +0 -203
  54. package/docs/ascii.md +0 -103
  55. package/docs/assets/improvements/box-start-connector.png +0 -0
  56. package/docs/assets/improvements/comment-dropped-lint.png +0 -0
  57. package/docs/assets/improvements/er-content-loss.png +0 -0
  58. package/docs/assets/improvements/fan-in-trunks.png +0 -0
  59. package/docs/assets/improvements/gantt-family.png +0 -0
  60. package/docs/assets/improvements/gantt-milestone-clamp.png +0 -0
  61. package/docs/assets/improvements/gantt-verify-seam.png +0 -0
  62. package/docs/assets/improvements/subgraph-container-edges.png +0 -0
  63. package/docs/assets/improvements/wrapper-frontmatter.png +0 -0
  64. package/docs/assets/improvements/wrapper-init-directive.png +0 -0
  65. package/docs/assets/improvements/wrapper-leading-comment.png +0 -0
  66. package/docs/assets/style-cookbook/mid-century-report.png +0 -0
  67. package/docs/assets/style-cookbook/star-chart-atlas.png +0 -0
  68. package/docs/assets/style-cookbook/transit-route-map.png +0 -0
  69. package/docs/comparison.md +0 -94
  70. package/docs/config.md +0 -105
  71. package/docs/contributing/adding-diagram-types.md +0 -144
  72. package/docs/contributing/diagram-family-citizenship.matrix.json +0 -2127
  73. package/docs/contributing/diagram-family-citizenship.md +0 -82
  74. package/docs/contributing/harvesting-upstream-tests.md +0 -126
  75. package/docs/contributing/lessons-learned.md +0 -276
  76. package/docs/contributing/releasing.md +0 -71
  77. package/docs/contributing/visual-review-evidence.md +0 -53
  78. package/docs/custom-style-cookbook.md +0 -154
  79. package/docs/design/families/README.md +0 -26
  80. package/docs/design/families/architecture-beta.md +0 -48
  81. package/docs/design/families/flowchart-parser-conformance.md +0 -22
  82. package/docs/design/families/gantt-research.md +0 -137
  83. package/docs/design/families/gantt.md +0 -329
  84. package/docs/design/families/journey-agentic-cards-before.png +0 -0
  85. package/docs/design/families/journey-mermaid-classic-implementation.png +0 -0
  86. package/docs/design/families/journey-mermaid-classic-prototype.png +0 -0
  87. package/docs/design/families/journey-mermaid-classic-prototype.svg +0 -163
  88. package/docs/design/families/journey-mermaid-classic-prototype.svg.png +0 -0
  89. package/docs/design/families/journey-migration-parity.md +0 -433
  90. package/docs/design/families/journey-usage-research.md +0 -423
  91. package/docs/design/families/journey.md +0 -125
  92. package/docs/design/families/xychart.md +0 -85
  93. package/docs/design/system/README.md +0 -45
  94. package/docs/design/system/abstraction-audit.md +0 -224
  95. package/docs/design/system/abstraction-recommendations.md +0 -655
  96. package/docs/design/system/architecture.mmd +0 -29
  97. package/docs/design/system/architecture.svg +0 -137
  98. package/docs/design/system/layout-guarantees-and-robustness.md +0 -351
  99. package/docs/design/system/layout-rubric.md +0 -138
  100. package/docs/design/system/route-contracts.md +0 -848
  101. package/docs/design/system/source-preservation-ladder.md +0 -52
  102. package/docs/design/system/styles-rollout.md +0 -216
  103. package/docs/design/system/ugly-layouts.md +0 -119
  104. package/docs/diagram-families.md +0 -193
  105. package/docs/features.md +0 -147
  106. package/docs/fork-differences.md +0 -130
  107. package/docs/getting-started.md +0 -175
  108. package/docs/issue-derived-test-cases.md +0 -156
  109. package/docs/layout-characterization/README.md +0 -245
  110. package/docs/layout-characterization/contact-sheet-families.md +0 -496
  111. package/docs/layout-characterization/contact-sheet.md +0 -638
  112. package/docs/layout-characterization/properties.md +0 -281
  113. package/docs/layout-characterization/visual-quality.md +0 -160
  114. package/docs/layout-characterization/visual-snapshots/architecture.svg +0 -79
  115. package/docs/layout-characterization/visual-snapshots/class.svg +0 -60
  116. package/docs/layout-characterization/visual-snapshots/er.svg +0 -61
  117. package/docs/layout-characterization/visual-snapshots/flowchart.svg +0 -58
  118. package/docs/layout-characterization/visual-snapshots/gantt.svg +0 -52
  119. package/docs/layout-characterization/visual-snapshots/journey.svg +0 -118
  120. package/docs/layout-characterization/visual-snapshots/pie.svg +0 -36
  121. package/docs/layout-characterization/visual-snapshots/quadrant.svg +0 -47
  122. package/docs/layout-characterization/visual-snapshots/sequence.svg +0 -59
  123. package/docs/layout-characterization/visual-snapshots/state.svg +0 -52
  124. package/docs/layout-characterization/visual-snapshots/timeline.svg +0 -64
  125. package/docs/layout-characterization/visual-snapshots/xychart.svg +0 -97
  126. package/docs/mcp-code-mode-rationale.md +0 -101
  127. package/docs/mcp-http-transport.md +0 -223
  128. package/docs/mermaid-layout-complaints.md +0 -837
  129. package/docs/mutation-testing.md +0 -206
  130. package/docs/project/divergences.md +0 -98
  131. package/docs/project/dogfooding-docs-strategy.md +0 -66
  132. package/docs/project/hosted-mcp-cloudflare-plan.md +0 -338
  133. package/docs/project/lessons-learned.md +0 -1019
  134. package/docs/project/remove-role-styling-plan.md +0 -176
  135. package/docs/quality.md +0 -212
  136. package/docs/react.md +0 -97
  137. package/docs/style-authoring.md +0 -133
  138. package/docs/testing-strategy.md +0 -335
  139. package/docs/theming.md +0 -162
  140. package/examples/agent-improve-auth-flow.ts +0 -206
  141. package/examples/agent-loop.ts +0 -53
  142. package/examples/mcp-vs-cli-complex-diagrams.ts +0 -133
  143. package/llms.txt +0 -104
  144. package/skill-evals/README.md +0 -77
  145. package/skill-evals/fixtures/architecture-cache/input.mmd +0 -5
  146. package/skill-evals/fixtures/class-payment/input.mmd +0 -4
  147. package/skill-evals/fixtures/er-orders/input.mmd +0 -10
  148. package/skill-evals/fixtures/flowchart-cache/input.mmd +0 -3
  149. package/skill-evals/fixtures/gantt-release/input.mmd +0 -7
  150. package/skill-evals/fixtures/journey-checkout/input.mmd +0 -6
  151. package/skill-evals/fixtures/pie-exports/input.mmd +0 -5
  152. package/skill-evals/fixtures/quadrant-priorities/input.mmd +0 -11
  153. package/skill-evals/fixtures/sequence-retry/input.mmd +0 -8
  154. package/skill-evals/fixtures/state-lifecycle/input.mmd +0 -5
  155. package/skill-evals/fixtures/timeline-release/input.mmd +0 -6
  156. package/skill-evals/fixtures/xychart-growth/input.mmd +0 -5
  157. package/skill-evals/shared-benchmark.json +0 -510
  158. package/src/agent/analyze.ts +0 -157
  159. package/src/agent/apply.ts +0 -146
  160. package/src/agent/architecture-body.ts +0 -458
  161. package/src/agent/certificates.ts +0 -110
  162. package/src/agent/class-body.ts +0 -319
  163. package/src/agent/core.ts +0 -207
  164. package/src/agent/create.ts +0 -106
  165. package/src/agent/describe.ts +0 -364
  166. package/src/agent/er-body.ts +0 -224
  167. package/src/agent/facts.ts +0 -337
  168. package/src/agent/families-builtin.ts +0 -934
  169. package/src/agent/families.ts +0 -232
  170. package/src/agent/family-layouts.ts +0 -826
  171. package/src/agent/flowchart-body.ts +0 -372
  172. package/src/agent/flowchart-unsupported.ts +0 -243
  173. package/src/agent/gantt-body.ts +0 -495
  174. package/src/agent/index.ts +0 -10
  175. package/src/agent/journey-body.ts +0 -414
  176. package/src/agent/label-metrics.ts +0 -28
  177. package/src/agent/layout-to-rendered.ts +0 -63
  178. package/src/agent/mutate.ts +0 -97
  179. package/src/agent/mutation-ops.ts +0 -51
  180. package/src/agent/op-schema.ts +0 -439
  181. package/src/agent/parse.ts +0 -223
  182. package/src/agent/pie-body.ts +0 -270
  183. package/src/agent/png.ts +0 -117
  184. package/src/agent/quadrant-body.ts +0 -336
  185. package/src/agent/quality.ts +0 -363
  186. package/src/agent/readability-audit.ts +0 -92
  187. package/src/agent/rendered-route-audit.ts +0 -61
  188. package/src/agent/sequence-body.ts +0 -345
  189. package/src/agent/serialize.ts +0 -214
  190. package/src/agent/state-body.ts +0 -589
  191. package/src/agent/structural-count.ts +0 -150
  192. package/src/agent/timeline-body.ts +0 -280
  193. package/src/agent/trace-log.ts +0 -43
  194. package/src/agent/types.ts +0 -979
  195. package/src/agent/verify.ts +0 -571
  196. package/src/agent/xychart-body.ts +0 -472
  197. package/src/architecture/config.ts +0 -282
  198. package/src/architecture/layout.ts +0 -496
  199. package/src/architecture/parser.ts +0 -375
  200. package/src/architecture/renderer.ts +0 -611
  201. package/src/architecture/types.ts +0 -113
  202. package/src/ascii/ansi.ts +0 -423
  203. package/src/ascii/architecture.ts +0 -210
  204. package/src/ascii/canvas.ts +0 -443
  205. package/src/ascii/class-diagram.ts +0 -697
  206. package/src/ascii/converter.ts +0 -475
  207. package/src/ascii/draw.ts +0 -1693
  208. package/src/ascii/edge-bundling.ts +0 -328
  209. package/src/ascii/edge-routing.ts +0 -330
  210. package/src/ascii/er-diagram.ts +0 -439
  211. package/src/ascii/gantt.ts +0 -276
  212. package/src/ascii/grid.ts +0 -747
  213. package/src/ascii/index.ts +0 -196
  214. package/src/ascii/journey.ts +0 -189
  215. package/src/ascii/meta.ts +0 -244
  216. package/src/ascii/multiline-utils.ts +0 -80
  217. package/src/ascii/pathfinder.ts +0 -301
  218. package/src/ascii/pie.ts +0 -67
  219. package/src/ascii/quadrant.ts +0 -158
  220. package/src/ascii/reverse.ts +0 -216
  221. package/src/ascii/sequence.ts +0 -499
  222. package/src/ascii/shapes/circle.ts +0 -27
  223. package/src/ascii/shapes/corners.ts +0 -143
  224. package/src/ascii/shapes/diamond.ts +0 -27
  225. package/src/ascii/shapes/hexagon.ts +0 -27
  226. package/src/ascii/shapes/index.ts +0 -106
  227. package/src/ascii/shapes/rectangle.ts +0 -168
  228. package/src/ascii/shapes/rounded.ts +0 -27
  229. package/src/ascii/shapes/special.ts +0 -332
  230. package/src/ascii/shapes/stadium.ts +0 -110
  231. package/src/ascii/shapes/state.ts +0 -192
  232. package/src/ascii/shapes/types.ts +0 -73
  233. package/src/ascii/timeline.ts +0 -128
  234. package/src/ascii/types.ts +0 -312
  235. package/src/ascii/validate.ts +0 -120
  236. package/src/ascii/width.ts +0 -81
  237. package/src/ascii/xychart.ts +0 -919
  238. package/src/browser.ts +0 -29
  239. package/src/class/layout.ts +0 -240
  240. package/src/class/parser.ts +0 -332
  241. package/src/class/renderer.ts +0 -638
  242. package/src/class/types.ts +0 -129
  243. package/src/cli/agent-instructions.ts +0 -1
  244. package/src/cli/am-bin.ts +0 -6
  245. package/src/cli/exit-codes.ts +0 -18
  246. package/src/cli/index.ts +0 -1117
  247. package/src/cli/init-agent.ts +0 -147
  248. package/src/color-resolver.ts +0 -152
  249. package/src/elk-instance.ts +0 -114
  250. package/src/er/layout.ts +0 -187
  251. package/src/er/parser.ts +0 -226
  252. package/src/er/renderer.ts +0 -792
  253. package/src/er/types.ts +0 -99
  254. package/src/font-manifest.ts +0 -27
  255. package/src/gantt/layout.ts +0 -365
  256. package/src/gantt/parser.ts +0 -282
  257. package/src/gantt/pipeline.ts +0 -27
  258. package/src/gantt/renderer.ts +0 -414
  259. package/src/gantt/schedule.ts +0 -513
  260. package/src/gantt/types.ts +0 -255
  261. package/src/index.ts +0 -368
  262. package/src/journey/layout.ts +0 -520
  263. package/src/journey/parser.ts +0 -218
  264. package/src/journey/renderer.ts +0 -811
  265. package/src/journey/types.ts +0 -144
  266. package/src/layout/geometry.ts +0 -182
  267. package/src/layout/pass.ts +0 -142
  268. package/src/layout/passes/index.ts +0 -3916
  269. package/src/layout-engine.ts +0 -1565
  270. package/src/layout-rubric.ts +0 -659
  271. package/src/mcp/artifacts.ts +0 -137
  272. package/src/mcp/dynamic-harness.ts +0 -31
  273. package/src/mcp/dynamic-modules.d.ts +0 -7
  274. package/src/mcp/facade.ts +0 -608
  275. package/src/mcp/harness-runtime.ts +0 -203
  276. package/src/mcp/hosted-server.ts +0 -468
  277. package/src/mcp/mcp-bin.ts +0 -3
  278. package/src/mcp/mcp-cli.ts +0 -82
  279. package/src/mcp/protocol.ts +0 -13
  280. package/src/mcp/sandbox.ts +0 -195
  281. package/src/mcp/sdk-decl.ts +0 -387
  282. package/src/mcp/server.ts +0 -362
  283. package/src/mcp/tool-surface.ts +0 -146
  284. package/src/mermaid-source.ts +0 -655
  285. package/src/multiline-utils.ts +0 -228
  286. package/src/parser.ts +0 -1031
  287. package/src/pie/layout.ts +0 -168
  288. package/src/pie/parser.ts +0 -136
  289. package/src/pie/renderer.ts +0 -234
  290. package/src/pie/types.ts +0 -82
  291. package/src/quadrant/layout.ts +0 -191
  292. package/src/quadrant/parser.ts +0 -183
  293. package/src/quadrant/renderer.ts +0 -322
  294. package/src/quadrant/types.ts +0 -113
  295. package/src/render-family-hooks.ts +0 -244
  296. package/src/renderer.ts +0 -1099
  297. package/src/route-contracts.ts +0 -2723
  298. package/src/scene/backend.ts +0 -76
  299. package/src/scene/fidelity.ts +0 -183
  300. package/src/scene/hybrid-backend.ts +0 -174
  301. package/src/scene/ir.ts +0 -181
  302. package/src/scene/marks.ts +0 -127
  303. package/src/scene/rough-backend.ts +0 -378
  304. package/src/scene/seed.ts +0 -46
  305. package/src/scene/style-registry.ts +0 -556
  306. package/src/sequence/layout.ts +0 -456
  307. package/src/sequence/parser.ts +0 -255
  308. package/src/sequence/renderer.ts +0 -604
  309. package/src/sequence/types.ts +0 -154
  310. package/src/shape-clipping.ts +0 -351
  311. package/src/shared/accessibility-directives.ts +0 -22
  312. package/src/shared/batched.ts +0 -61
  313. package/src/shared/color-math.ts +0 -72
  314. package/src/shared/svg-a11y.ts +0 -29
  315. package/src/shared/syntax-error.ts +0 -17
  316. package/src/shared/unicode-ranges.ts +0 -135
  317. package/src/styles.ts +0 -318
  318. package/src/svg-paths.ts +0 -28
  319. package/src/text-metrics.ts +0 -248
  320. package/src/theme.ts +0 -639
  321. package/src/timeline/layout.ts +0 -381
  322. package/src/timeline/parser.ts +0 -215
  323. package/src/timeline/renderer.ts +0 -538
  324. package/src/timeline/types.ts +0 -105
  325. package/src/types.ts +0 -454
  326. package/src/website-worker-modules.d.ts +0 -18
  327. package/src/xychart/axis-utils.ts +0 -109
  328. package/src/xychart/colors.ts +0 -124
  329. package/src/xychart/config.ts +0 -88
  330. package/src/xychart/layout.ts +0 -656
  331. package/src/xychart/parser.ts +0 -504
  332. package/src/xychart/renderer.ts +0 -640
  333. package/src/xychart/types.ts +0 -282
@@ -1,2723 +0,0 @@
1
- /**
2
- * Route contracts — principled routing without hitches (docs/design/system/route-contracts.md).
3
- *
4
- * ELK's FREE port placement spreads edge endpoints along node sides, so a
5
- * forward edge between two nodes that share a centerline often gets a short
6
- * perpendicular jog ("hitch") even though nothing blocks the direct lane.
7
- * This module classifies every edge by semantic role, then collapses a
8
- * primary-forward route to a straight lane only when it can prove the lane
9
- * is clear; every other route gets a certificate explaining its bends.
10
- *
11
- * Runs at the very end of elkToPositioned(): nothing may move nodes or edit
12
- * edge geometry after this pass without recertifying.
13
- */
14
-
15
- import type {
16
- AnyPort,
17
- DiamondFacet,
18
- Direction,
19
- MermaidGraph,
20
- Point,
21
- PortSemanticRole,
22
- PortSide,
23
- PositionedEdge,
24
- PositionedGroup,
25
- PositionedNode,
26
- RouteBlocker,
27
- RouteCertificate,
28
- RouteClass,
29
- RouteInvariant,
30
- RoutePortAssignment,
31
- } from './types.ts'
32
- import { measureMultilineText } from './text-metrics.ts'
33
- import { applyTextTransform, resolveRenderStyle } from './styles.ts'
34
-
35
- type DraftRouteCertificate = Omit<RouteCertificate, 'invariant' | 'straightened'> & {
36
- invariant: RouteInvariant
37
- /** Internal draft bit; finalized public certificates only keep this for straight routes. */
38
- straightened?: true
39
- }
40
-
41
- /** Two coordinates within this distance are the same point. */
42
- const EPS = 0.01
43
- /** Straightened endpoints keep this inset from rectangle side ends. */
44
- const SPAN_MARGIN = 4
45
- /** Obstacle bounding boxes are inflated by this much when proving a lane clear. */
46
- const CLEARANCE = 4
47
- /** Required free space around a label hosted on a straightened lane. */
48
- const LABEL_CLEARANCE = 8
49
- /** The renderer draws labels as pills with this padding around the measured text. */
50
- const LABEL_PILL_PADDING = 8
51
-
52
- /** Shapes whose forward-facing side is a straight axis-aligned segment. */
53
- const RECT_LIKE = new Set(['rectangle', 'service', 'rounded', 'subroutine'])
54
-
55
- /**
56
- * Shapes whose four canonical ports (bbox side midpoints — see PortSide) lie
57
- * exactly on the rendered outline: symmetric shapes inscribed in their bbox.
58
- * Rect-likes additionally accept attachment anywhere on a side (dynamic
59
- * glue); diamonds anywhere on a facet; the rest are port-only.
60
- */
61
- export const PORT_EXACT = new Set([
62
- ...RECT_LIKE, 'diamond',
63
- 'circle', 'doublecircle', 'stadium', 'hexagon', 'cylinder', 'state-start', 'state-end',
64
- // The slanted family: their N/S ports sit on flat bbox-edge regions and
65
- // their E/W ports move inward onto the slant midpoints (see shapePorts).
66
- 'trapezoid', 'trapezoid-alt', 'asymmetric', 'lean-r', 'lean-l',
67
- ])
68
-
69
- /** Shapes whose E/W sides are slants sheared in by width * SLANT_INSET_RATIO
70
- * (renderer geometry): the E/W port main coordinate moves onto the slant
71
- * midpoint, all other port coordinates stay at the bbox side midpoints. */
72
- const SLANTED = new Set(['trapezoid', 'trapezoid-alt', 'lean-r', 'lean-l'])
73
- const SLANT_INSET_RATIO = 0.15
74
-
75
- /** Tolerance for recognizing an endpoint as sitting on a port. */
76
- const PORT_TOLERANCE = 0.5
77
-
78
- /** Cross-axis distance between the two lines of a reciprocal pair. */
79
- const PAIR_SEPARATION = 12
80
-
81
- /** Minimum length of a vertex hook's entry stub (the perpendicular segment
82
- * into the target's facing port) — shorter reads as a hitch, not an entry. */
83
- const HOOK_STUB_MIN = 8
84
-
85
- /** The four canonical connection points. Every port keeps its CROSS
86
- * coordinate at the bbox center (cy for E/W, cx for N/S) — the whole engine
87
- * assumes port lanes run through node centers. Only the MAIN coordinate
88
- * moves onto the rendered outline:
89
- * - symmetric shapes (rect-likes, diamond, circle, stadium, hexagon,
90
- * cylinder, pseudostates): bbox side midpoints — diamond vertices,
91
- * circle extremes, hexagon E/W vertices and cylinder cap centers
92
- * coincide with them.
93
- * - the slanted family (trapezoid, trapezoid-alt, lean-r, lean-l): E/W
94
- * move inward to the slant midpoints at mid-height (x + i/2 and
95
- * x + w - i/2 with i = w * 0.15 — identical for all four by symmetry);
96
- * N/S stay at (cx, y)/(cx, y+h), which lie on the flats since i < w/2.
97
- * - asymmetric: W is the flag point (x, cy); E/N/S are bbox midpoints
98
- * (cx lies on both flats whenever w > 24 — nodes are always wider). */
99
- export function shapePorts(node: PositionedNode): Record<PortSide, Point> {
100
- const cx = node.x + node.width / 2
101
- const cy = node.y + node.height / 2
102
- let east = node.x + node.width
103
- let west = node.x
104
- if (SLANTED.has(node.shape)) {
105
- const inset = node.width * SLANT_INSET_RATIO
106
- east = node.x + node.width - inset / 2
107
- west = node.x + inset / 2
108
- }
109
- return {
110
- N: { x: cx, y: node.y },
111
- E: { x: east, y: cy },
112
- S: { x: cx, y: node.y + node.height },
113
- W: { x: west, y: cy },
114
- }
115
- }
116
-
117
- /**
118
- * The four facet-midpoints of a diamond — the points halfway along each
119
- * slanted edge. For a diamond at (x,y,w,h) with cx=x+w/2, cy=y+h/2 these are
120
- * the averages of the adjacent vertices: NE/SE/SW/NW at (x±w/4 offset from cx,
121
- * y±h/4 offset from cy). All four lie exactly on the rendered outline
122
- * (|dx|/hw + |dy|/hh = 1). Diamond-only: every other shape keeps its four
123
- * cardinal ports via shapePorts(); this is a strictly additive attachment set.
124
- */
125
- export function diamondFacetPorts(node: PositionedNode): Record<DiamondFacet, Point> {
126
- const cx = node.x + node.width / 2
127
- const cy = node.y + node.height / 2
128
- const qw = node.width / 4
129
- const qh = node.height / 4
130
- return {
131
- NE: { x: cx + qw, y: cy - qh },
132
- SE: { x: cx + qw, y: cy + qh },
133
- SW: { x: cx - qw, y: cy + qh },
134
- NW: { x: cx - qw, y: cy - qh },
135
- }
136
- }
137
-
138
- /**
139
- * The port (if any) an endpoint sits on, within PORT_TOLERANCE. The four
140
- * cardinal side-midpoints are checked first for EVERY shape (byte-identical to
141
- * the legacy four-cardinal probe), so non-diamonds and diamond cardinal
142
- * attachments are unchanged. A diamond endpoint that misses all four cardinals
143
- * is additionally tested against the facet-midpoints, returning NE/SE/SW/NW.
144
- */
145
- function portAt(node: PositionedNode, pt: Point): AnyPort | undefined {
146
- const ports = shapePorts(node)
147
- for (const side of ['N', 'E', 'S', 'W'] as const) {
148
- if (Math.abs(ports[side].x - pt.x) <= PORT_TOLERANCE && Math.abs(ports[side].y - pt.y) <= PORT_TOLERANCE) return side
149
- }
150
- if (node.shape === 'diamond') {
151
- const facets = diamondFacetPorts(node)
152
- for (const f of ['NE', 'SE', 'SW', 'NW'] as const) {
153
- if (Math.abs(facets[f].x - pt.x) <= PORT_TOLERANCE && Math.abs(facets[f].y - pt.y) <= PORT_TOLERANCE) return f
154
- }
155
- }
156
- return undefined
157
- }
158
-
159
- /** Straightenable: rect-likes and diamonds anywhere on a side/facet; other
160
- * PORT_EXACT shapes only through their exact ports. */
161
- function isStraightenable(shape: PositionedNode['shape']): boolean {
162
- return PORT_EXACT.has(shape)
163
- }
164
-
165
- export interface LabelMetricsStyle {
166
- edgeLabelFontSize: number
167
- edgeLabelFontWeight: number
168
- edgeTextTransform?: 'uppercase' | 'lowercase' | 'capitalize'
169
- }
170
-
171
- // ============================================================================
172
- // Classification
173
- // ============================================================================
174
-
175
- /**
176
- * Classify every edge of the graph by author-order semantics. Mirrors the
177
- * cycle-tolerant reachability used by sourceAwareNodeOrder() so the route
178
- * class always agrees with the model order ELK laid out.
179
- */
180
- export function classifyRoutes(graph: MermaidGraph): RouteClass[] {
181
- const subgraphIds = new Set<string>()
182
- const scopeOf = new Map<string, string>()
183
- const visitScope = (sgs: MermaidGraph['subgraphs'], parent: string) => {
184
- for (const sg of sgs) {
185
- subgraphIds.add(sg.id)
186
- for (const nodeId of sg.nodeIds) scopeOf.set(nodeId, sg.id)
187
- visitScope(sg.children, sg.id)
188
- }
189
- }
190
- visitScope(graph.subgraphs, '')
191
-
192
- const outgoing = new Map<string, Set<string>>()
193
- const reaches = (from: string, to: string): boolean => {
194
- const seen = new Set<string>()
195
- const stack = [from]
196
- while (stack.length > 0) {
197
- const id = stack.pop()!
198
- if (id === to) return true
199
- if (seen.has(id)) continue
200
- seen.add(id)
201
- for (const next of outgoing.get(id) ?? []) stack.push(next)
202
- }
203
- return false
204
- }
205
-
206
- return graph.edges.map(edge => {
207
- if (edge.source === edge.target) return 'self-loop'
208
- if (subgraphIds.has(edge.source) || subgraphIds.has(edge.target)) return 'container'
209
- const cls: RouteClass = reaches(edge.target, edge.source)
210
- ? 'feedback'
211
- : (scopeOf.get(edge.source) ?? '') !== (scopeOf.get(edge.target) ?? '')
212
- ? 'cross-hierarchy'
213
- : 'primary-forward'
214
- if (cls !== 'feedback') {
215
- if (!outgoing.has(edge.source)) outgoing.set(edge.source, new Set())
216
- outgoing.get(edge.source)!.add(edge.target)
217
- }
218
- return cls
219
- })
220
- }
221
-
222
- // ============================================================================
223
- // Geometry helpers
224
- // ============================================================================
225
-
226
- interface Axis {
227
- main: 'x' | 'y'
228
- cross: 'x' | 'y'
229
- /** +1 when forward flow increases the main coordinate. */
230
- sign: 1 | -1
231
- }
232
-
233
- function axisFor(direction: Direction): Axis {
234
- switch (direction) {
235
- case 'LR': return { main: 'x', cross: 'y', sign: 1 }
236
- case 'RL': return { main: 'x', cross: 'y', sign: -1 }
237
- case 'BT': return { main: 'y', cross: 'x', sign: -1 }
238
- default: return { main: 'y', cross: 'x', sign: 1 } // TD / TB
239
- }
240
- }
241
-
242
- /**
243
- * Remove consecutive duplicates and collinear midpoints. Preserves drawn
244
- * geometry exactly. Iterates to a fixed point: ELK can emit degenerate
245
- * zero-net spikes (out-and-back excursions) whose midpoints only become
246
- * collinear after their neighbors are removed — a single pass would leave a
247
- * phantom collinear point behind.
248
- */
249
- export function simplifyPolyline(points: Point[]): Point[] {
250
- let current = points
251
- for (let iterations = 0; iterations < 8; iterations++) {
252
- if (current.length < 3) return current
253
- const out: Point[] = [current[0]!]
254
- for (let i = 1; i < current.length; i++) {
255
- const p = current[i]!
256
- const prev = out[out.length - 1]!
257
- if (Math.abs(p.x - prev.x) < EPS && Math.abs(p.y - prev.y) < EPS) continue
258
- out.push(p)
259
- }
260
- const result: Point[] = out.length < 3 ? out : [out[0]!]
261
- if (out.length >= 3) {
262
- for (let i = 1; i < out.length - 1; i++) {
263
- const a = result[result.length - 1]!, b = out[i]!, c = out[i + 1]!
264
- const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
265
- if (Math.abs(cross) < EPS) continue // collinear midpoint (or spike apex)
266
- result.push(b)
267
- }
268
- result.push(out[out.length - 1]!)
269
- }
270
- if (result.length === current.length) return result
271
- current = result
272
- }
273
- return current
274
- }
275
-
276
- /**
277
- * Replace any diagonal segment with an axis-aligned elbow. ELK's orthogonal
278
- * router emits diagonals in rare feedback-port joins; downstream contracts
279
- * (and the renderer's orthogonality guarantee) assume axis-aligned segments.
280
- * The elbow continues the PREVIOUS segment's axis for visual continuity.
281
- */
282
- function orthogonalizeResidualDiagonals(points: Point[]): Point[] {
283
- let needsWork = false
284
- for (let i = 1; i < points.length; i++) {
285
- const a = points[i - 1]!, b = points[i]!
286
- if (Math.abs(a.x - b.x) > EPS && Math.abs(a.y - b.y) > EPS) { needsWork = true; break }
287
- }
288
- if (!needsWork) return points
289
- const out: Point[] = [points[0]!]
290
- for (let i = 1; i < points.length; i++) {
291
- const a = out[out.length - 1]!, b = points[i]!
292
- if (Math.abs(a.x - b.x) > EPS && Math.abs(a.y - b.y) > EPS) {
293
- const prev = out.length >= 2 ? out[out.length - 2]! : undefined
294
- const prevHorizontal = prev ? Math.abs(prev.y - a.y) < EPS : true
295
- out.push(prevHorizontal ? { x: b.x, y: a.y } : { x: a.x, y: b.y })
296
- }
297
- out.push(b)
298
- }
299
- return out
300
- }
301
-
302
- /** Interior vertex count of a polyline. */
303
- function bendCount(points: Point[]): number {
304
- return Math.max(0, simplifyPolyline(points).length - 2)
305
- }
306
-
307
- /** Max perpendicular deviation of a route from its flow axis. */
308
- function crossDeviation(points: Point[], axis: Axis): number {
309
- let lo = Infinity, hi = -Infinity
310
- for (const p of points) {
311
- lo = Math.min(lo, p[axis.cross])
312
- hi = Math.max(hi, p[axis.cross])
313
- }
314
- return hi - lo
315
- }
316
-
317
- /**
318
- * A route is a monotone staircase when every segment is axis-aligned and all
319
- * flow-axis displacement runs forward. Only these are straightening candidates.
320
- */
321
- function isMonotoneStaircase(points: Point[], axis: Axis): boolean {
322
- for (let i = 1; i < points.length; i++) {
323
- const a = points[i - 1]!, b = points[i]!
324
- const dMain = (b[axis.main] - a[axis.main]) * axis.sign
325
- const dCross = Math.abs(b[axis.cross] - a[axis.cross])
326
- if (dMain > EPS && dCross > EPS) return false // diagonal segment
327
- if (dMain < -EPS) return false // moves against the flow
328
- }
329
- return true
330
- }
331
-
332
- // ============================================================================
333
- // Attachment spans and anchors
334
- // ============================================================================
335
-
336
- interface CrossSpan { lo: number; hi: number }
337
-
338
- /** A straightened lane keeps this cross-axis distance from a diamond vertex. */
339
- const VERTEX_MARGIN = 10
340
-
341
- /** Cross-axis range where a straightened lane may attach to this node. */
342
- function attachSpan(node: PositionedNode, axis: Axis): CrossSpan | null {
343
- const lo = node[axis.cross]
344
- const size = axis.cross === 'y' ? node.height : node.width
345
- if (node.shape === 'diamond') {
346
- // Anywhere on the facet except a small absolute margin around the
347
- // vertices — flowchart convention attaches along the whole slanted edge
348
- // (a proportional restriction needlessly forbade straight lanes whose
349
- // partner node sits off the diamond's centerline).
350
- if (size <= VERTEX_MARGIN * 4) {
351
- const center = lo + size / 2
352
- return { lo: center - size / 4, hi: center + size / 4 }
353
- }
354
- return { lo: lo + VERTEX_MARGIN, hi: lo + size - VERTEX_MARGIN }
355
- }
356
- if (RECT_LIKE.has(node.shape)) {
357
- if (size <= SPAN_MARGIN * 2) return null
358
- return { lo: lo + SPAN_MARGIN, hi: lo + size - SPAN_MARGIN }
359
- }
360
- // Shapes with one FLAT pair of sides accept attachment anywhere on the
361
- // flat region (it lies exactly on the bbox edge); their curved/pointed
362
- // pair stays port-only. This is the geometric form of yFiles' overflow
363
- // port candidates: the side midpoint is the preferred anchor, the rest of
364
- // the flat side takes the overflow.
365
- if (node.shape === 'hexagon' && axis.cross === 'x') {
366
- const inset = node.height / 4
367
- if (size - 2 * inset <= SPAN_MARGIN) return null
368
- return { lo: lo + inset + 2, hi: lo + size - inset - 2 } // flat N/S region
369
- }
370
- if (node.shape === 'stadium' && axis.cross === 'x') {
371
- const r = node.height / 2
372
- if (size - 2 * r <= SPAN_MARGIN) return null
373
- return { lo: lo + r + 2, hi: lo + size - r - 2 } // flat N/S region
374
- }
375
- if (node.shape === 'cylinder' && axis.cross === 'y') {
376
- const RY = 7
377
- if (size - 2 * RY <= SPAN_MARGIN) return null
378
- return { lo: lo + RY + 2, hi: lo + size - RY - 2 } // straight E/W walls
379
- }
380
- // The slanted family in vertical flow: attachment anywhere on the
381
- // INTERSECTION of the two flat sides, so one span works for both N and S
382
- // (trapezoid: top flat [x+i, x+w-i] ∩ bottom flat [x, x+w]; lean-r/lean-l:
383
- // [x+i, x+w] ∩ [x, x+w-i] — the same [x+i, x+w-i] for all four). Their
384
- // slanted E/W sides stay port-only via the PORT_EXACT fallback below.
385
- if (SLANTED.has(node.shape) && axis.cross === 'x') {
386
- const inset = node.width * SLANT_INSET_RATIO
387
- if (size - 2 * inset <= SPAN_MARGIN * 2) return null
388
- return { lo: lo + inset + SPAN_MARGIN, hi: lo + size - inset - SPAN_MARGIN }
389
- }
390
- // Asymmetric in vertical flow: both flats span [x+12, x+w] (the flag point
391
- // only indents the W side). Its pointed/straight E-W pair is port-only.
392
- if (node.shape === 'asymmetric' && axis.cross === 'x') {
393
- const POINT_INDENT = 12
394
- if (size - POINT_INDENT <= SPAN_MARGIN * 2) return null
395
- return { lo: lo + POINT_INDENT + SPAN_MARGIN, hi: lo + size - SPAN_MARGIN }
396
- }
397
- if (PORT_EXACT.has(node.shape)) {
398
- // Fully curved or pointed on this axis (circle; stadium ends; hexagon
399
- // tips; cylinder caps): only the exact side-midpoint port is on the
400
- // outline at the bbox edge.
401
- const center = lo + size / 2
402
- return { lo: center - PORT_TOLERANCE, hi: center + PORT_TOLERANCE }
403
- }
404
- return null // shape's outline may not contain a bbox-side anchor; skip
405
- }
406
-
407
- /**
408
- * Main-axis coordinate where a straight lane at cross-coordinate `c` meets the
409
- * node's rendered boundary. `facing` is +1 for the side facing forward flow
410
- * (source side), -1 for the side facing backward (target side).
411
- */
412
- function anchorMain(node: PositionedNode, c: number, axis: Axis, facing: 1 | -1): number {
413
- const mainLo = node[axis.main]
414
- const mainSize = axis.main === 'x' ? node.width : node.height
415
- if (node.shape === 'diamond') {
416
- const crossLo = node[axis.cross]
417
- const crossSize = axis.cross === 'y' ? node.height : node.width
418
- const centerMain = mainLo + mainSize / 2
419
- const centerCross = crossLo + crossSize / 2
420
- const slope = 1 - Math.abs(c - centerCross) / (crossSize / 2)
421
- return centerMain + facing * axis.sign * (mainSize / 2) * slope
422
- }
423
- // The slanted family in horizontal flow is port-only (c ≈ cy), so the
424
- // boundary main coordinate is the slant midpoint — the shapePorts E/W main
425
- // (x + w - i/2 forward, x + i/2 backward; identical for all four shapes by
426
- // symmetry). In vertical flow their flats sit at the bbox edge (y / y+h),
427
- // so the rect default below applies. Asymmetric needs no case at all: its
428
- // W point (x, cy) and E side (x+w) ARE the bbox extremes.
429
- if (SLANTED.has(node.shape) && axis.main === 'x') {
430
- const inset = node.width * SLANT_INSET_RATIO
431
- return facing * axis.sign > 0 ? mainLo + mainSize - inset / 2 : mainLo + inset / 2
432
- }
433
- return facing * axis.sign > 0 ? mainLo + mainSize : mainLo
434
- }
435
-
436
- // ============================================================================
437
- // Direct-lane proof
438
- // ============================================================================
439
-
440
- interface LaneContext {
441
- nodes: PositionedNode[]
442
- edges: PositionedEdge[]
443
- axis: Axis
444
- style: LabelMetricsStyle
445
- /** Route classes by edgeIndex — lets proofs apply the primary-over-feedback priority. */
446
- classes?: RouteClass[]
447
- /** Lines per (nodeId:side) — the port-ranking occupancy (see facingSide). */
448
- sideUse?: Map<string, number>
449
- }
450
-
451
- /** The side of a node that faces along `axis` — facing=1 for the side an
452
- * edge leaves from, facing=-1 for the side it enters. */
453
- function facingSide(axis: Axis, facing: 1 | -1): PortSide {
454
- const forward = facing * axis.sign > 0
455
- return axis.main === 'x' ? (forward ? 'E' : 'W') : (forward ? 'S' : 'N')
456
- }
457
-
458
- interface RoutePortEndpointDraft {
459
- edge: PositionedEdge
460
- endpoint: 'source' | 'target'
461
- nodeId: string
462
- side: PortSide
463
- role: PortSemanticRole
464
- coord: number
465
- edgeIndex: number
466
- port?: AnyPort
467
- }
468
-
469
- export interface RoutePortAllocation {
470
- byEdge: Map<PositionedEdge, { source?: RoutePortAssignment; target?: RoutePortAssignment }>
471
- /** Counts only endpoints that compete for the flow-side ranking rules. */
472
- sideUse: Map<string, number>
473
- }
474
-
475
- function facetSides(port: DiamondFacet): [PortSide, PortSide] {
476
- switch (port) {
477
- case 'NE': return ['N', 'E']
478
- case 'SE': return ['S', 'E']
479
- case 'SW': return ['S', 'W']
480
- case 'NW': return ['N', 'W']
481
- }
482
- }
483
-
484
- function sideForPort(port: AnyPort | undefined, semanticSide: PortSide): PortSide {
485
- if (!port) return semanticSide
486
- if (port === 'N' || port === 'E' || port === 'S' || port === 'W') return port
487
- const sides = facetSides(port)
488
- return sides.includes(semanticSide) ? semanticSide : sides[0]
489
- }
490
-
491
- function roleAndSide(routeClass: RouteClass, endpoint: 'source' | 'target', axis: Axis): { role: PortSemanticRole; side: PortSide } {
492
- const source = endpoint === 'source'
493
- switch (routeClass) {
494
- case 'feedback': {
495
- const flipped: Axis = { ...axis, sign: axis.sign === 1 ? -1 : 1 }
496
- return {
497
- role: source ? 'feedback-source' : 'feedback-target',
498
- side: facingSide(flipped, source ? 1 : -1),
499
- }
500
- }
501
- case 'self-loop':
502
- return {
503
- role: source ? 'self-loop-source' : 'self-loop-target',
504
- side: facingSide(axis, source ? 1 : -1),
505
- }
506
- case 'container':
507
- return {
508
- role: source ? 'container-source' : 'container-target',
509
- side: facingSide(axis, source ? 1 : -1),
510
- }
511
- case 'cross-hierarchy':
512
- return {
513
- role: source ? 'cross-hierarchy-source' : 'cross-hierarchy-target',
514
- side: facingSide(axis, source ? 1 : -1),
515
- }
516
- default:
517
- return {
518
- role: source ? 'flow-source' : 'flow-target',
519
- side: facingSide(axis, source ? 1 : -1),
520
- }
521
- }
522
- }
523
-
524
- function sideOrderCoordinate(side: PortSide, node: PositionedNode, point: Point | undefined): number {
525
- if (point) return side === 'N' || side === 'S' ? point.x : point.y
526
- return side === 'N' || side === 'S' ? node.x + node.width / 2 : node.y + node.height / 2
527
- }
528
-
529
- /**
530
- * Dynamic port allocator: for every node endpoint, record the physical side,
531
- * deterministic slot order along that side, and semantic role (flow emit,
532
- * feedback return, container attach, etc.). `sourcePort`/`targetPort` remain
533
- * the exact V1 port vocabulary; this side/slot/role layer is additive and can
534
- * be fed into pre-layout placement without reinterpreting those exact ports.
535
- */
536
- export function allocateRoutePorts(
537
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[] },
538
- graph: MermaidGraph,
539
- classes: RouteClass[] = classifyRoutes(graph),
540
- ): RoutePortAllocation {
541
- const axis = axisFor(graph.direction)
542
- const nodeMap = new Map(positioned.nodes.map(n => [n.id, n]))
543
- const drafts: RoutePortEndpointDraft[] = []
544
- const sideUse = new Map<string, number>()
545
- const bumpSideUse = (nodeId: string, side: PortSide) => {
546
- const key = `${nodeId}:${side}`
547
- sideUse.set(key, (sideUse.get(key) ?? 0) + 1)
548
- }
549
-
550
- for (const edge of positioned.edges) {
551
- if (edge.edgeIndex === undefined) continue
552
- const routeClass = classes[edge.edgeIndex] ?? 'primary-forward'
553
- for (const endpoint of ['source', 'target'] as const) {
554
- const nodeId = endpoint === 'source' ? edge.source : edge.target
555
- const node = nodeMap.get(nodeId)
556
- if (!node) continue
557
- const { role, side: semanticSide } = roleAndSide(routeClass, endpoint, axis)
558
- const point = endpoint === 'source' ? edge.points[0] : edge.points[edge.points.length - 1]
559
- const port = point ? portAt(node, point) : undefined
560
- const side = sideForPort(port, semanticSide)
561
- const competes = routeClass === 'primary-forward' || (routeClass === 'feedback' && !edge.label)
562
- if (competes) bumpSideUse(nodeId, semanticSide)
563
- drafts.push({
564
- edge,
565
- endpoint,
566
- nodeId,
567
- side,
568
- role,
569
- coord: sideOrderCoordinate(side, node, point),
570
- edgeIndex: edge.edgeIndex,
571
- port,
572
- })
573
- }
574
- }
575
-
576
- const groups = new Map<string, RoutePortEndpointDraft[]>()
577
- for (const draft of drafts) {
578
- const key = `${draft.nodeId}:${draft.side}`
579
- if (!groups.has(key)) groups.set(key, [])
580
- groups.get(key)!.push(draft)
581
- }
582
-
583
- const byEdge = new Map<PositionedEdge, { source?: RoutePortAssignment; target?: RoutePortAssignment }>()
584
- const endpointRank = (endpoint: 'source' | 'target') => endpoint === 'source' ? 0 : 1
585
- for (const group of groups.values()) {
586
- group.sort((a, b) =>
587
- a.coord - b.coord || a.edgeIndex - b.edgeIndex || endpointRank(a.endpoint) - endpointRank(b.endpoint))
588
- const slotCount = group.length
589
- group.forEach((draft, slotIndex) => {
590
- const assignment: RoutePortAssignment = {
591
- side: draft.side,
592
- slotIndex,
593
- slotCount,
594
- role: draft.role,
595
- ...(draft.port ? { port: draft.port } : {}),
596
- }
597
- const entry = byEdge.get(draft.edge) ?? {}
598
- entry[draft.endpoint] = assignment
599
- byEdge.set(draft.edge, entry)
600
- })
601
- }
602
-
603
- return { byEdge, sideUse }
604
- }
605
-
606
- /** Port-ranking sharpness: a diamond's vertex is the strongest anchor. */
607
- function portSharpness(shape: PositionedNode['shape']): number {
608
- return shape === 'diamond' ? 2 : 1
609
- }
610
-
611
- function portPoint(node: PositionedNode, side: PortSide | DiamondFacet): Point {
612
- if (node.shape === 'diamond' && (side === 'NE' || side === 'SE' || side === 'SW' || side === 'NW')) {
613
- return diamondFacetPorts(node)[side]
614
- }
615
- return shapePorts(node)[side as PortSide]
616
- }
617
-
618
- function repairRectLikeEndpointOverflow(edge: PositionedEdge, node: PositionedNode, isStart: boolean): void {
619
- if (!RECT_LIKE.has(node.shape) || edge.points.length < 2) return
620
- const idx = isStart ? 0 : edge.points.length - 1
621
- const p = edge.points[idx]!
622
- const x0 = node.x
623
- const x1 = node.x + node.width
624
- const y0 = node.y
625
- const y1 = node.y + node.height
626
- const near = (a: number, b: number) => Math.abs(a - b) <= PORT_TOLERANCE
627
- const pushStub = (outline: Point, stub: Point) => {
628
- edge.points[idx] = outline
629
- if (Math.abs(outline.x - stub.x) <= EPS && Math.abs(outline.y - stub.y) <= EPS) return
630
- if (isStart) {
631
- const next = edge.points[1]
632
- if (!next || Math.abs(next.x - stub.x) > EPS || Math.abs(next.y - stub.y) > EPS) edge.points.splice(1, 0, stub)
633
- } else {
634
- const prev = edge.points[edge.points.length - 2]
635
- if (!prev || Math.abs(prev.x - stub.x) > EPS || Math.abs(prev.y - stub.y) > EPS) edge.points.splice(edge.points.length - 1, 0, stub)
636
- }
637
- }
638
-
639
- if (near(p.x, x0) || near(p.x, x1)) {
640
- const x = near(p.x, x0) ? x0 : x1
641
- if (p.y < y0 - PORT_TOLERANCE) pushStub({ x, y: y0 }, { x, y: p.y })
642
- else if (p.y > y1 + PORT_TOLERANCE) pushStub({ x, y: y1 }, { x, y: p.y })
643
- return
644
- }
645
- if (near(p.y, y0) || near(p.y, y1)) {
646
- const y = near(p.y, y0) ? y0 : y1
647
- if (p.x < x0 - PORT_TOLERANCE) pushStub({ x: x0, y }, { x: p.x, y })
648
- else if (p.x > x1 + PORT_TOLERANCE) pushStub({ x: x1, y }, { x: p.x, y })
649
- }
650
- }
651
-
652
- function diamondSpreadPortSet(axis: Axis, count: number): Array<PortSide | DiamondFacet> | null {
653
- if (axis.main === 'x') {
654
- if (axis.sign > 0) {
655
- if (count === 2) return ['NE', 'SE']
656
- if (count === 3) return ['NE', 'E', 'SE']
657
- } else {
658
- if (count === 2) return ['NW', 'SW']
659
- if (count === 3) return ['NW', 'W', 'SW']
660
- }
661
- } else if (axis.sign > 0) {
662
- if (count === 2) return ['SW', 'SE']
663
- if (count === 3) return ['SW', 'S', 'SE']
664
- } else {
665
- if (count === 2) return ['NW', 'NE']
666
- if (count === 3) return ['NW', 'N', 'NE']
667
- }
668
- return null
669
- }
670
-
671
- interface DiamondSpreadAssignment {
672
- sourcePort: PortSide | DiamondFacet
673
- sourceLane: number
674
- }
675
-
676
- function diamondSpreadAssignment(
677
- edge: PositionedEdge,
678
- source: PositionedNode,
679
- ctx: LaneContext,
680
- axis: Axis,
681
- ): DiamondSpreadAssignment | null {
682
- if (source.shape !== 'diamond' || edge.edgeIndex === undefined || ctx.classes?.[edge.edgeIndex] !== 'primary-forward') {
683
- return null
684
- }
685
- const nodeMap = new Map(ctx.nodes.map(node => [node.id, node]))
686
- const siblings = ctx.edges
687
- .filter(candidate => candidate.source === edge.source &&
688
- candidate.edgeIndex !== undefined &&
689
- ctx.classes?.[candidate.edgeIndex] === 'primary-forward' &&
690
- nodeMap.has(candidate.target))
691
- .sort((a, b) => {
692
- const aTarget = nodeMap.get(a.target)!
693
- const bTarget = nodeMap.get(b.target)!
694
- const aCross = aTarget[axis.cross] + (axis.cross === 'y' ? aTarget.height : aTarget.width) / 2
695
- const bCross = bTarget[axis.cross] + (axis.cross === 'y' ? bTarget.height : bTarget.width) / 2
696
- return aCross - bCross || (a.edgeIndex ?? 0) - (b.edgeIndex ?? 0)
697
- })
698
- const ports = diamondSpreadPortSet(axis, siblings.length)
699
- if (!ports) return null
700
- const index = siblings.indexOf(edge)
701
- if (index < 0) return null
702
- const sourcePort = ports[index]!
703
- return { sourcePort, sourceLane: portPoint(source, sourcePort)[axis.cross] }
704
- }
705
-
706
- function diamondSpreadLane(
707
- edge: PositionedEdge,
708
- source: PositionedNode,
709
- ctx: LaneContext,
710
- axis: Axis,
711
- ): number | undefined {
712
- return diamondSpreadAssignment(edge, source, ctx, axis)?.sourceLane
713
- }
714
-
715
- function diamondSpreadTargetsShareRank(
716
- edge: PositionedEdge,
717
- source: PositionedNode,
718
- ctx: LaneContext,
719
- axis: Axis,
720
- ): boolean {
721
- if (source.shape !== 'diamond' || edge.edgeIndex === undefined || ctx.classes?.[edge.edgeIndex] !== 'primary-forward') {
722
- return false
723
- }
724
- const nodeMap = new Map(ctx.nodes.map(node => [node.id, node]))
725
- const siblings = ctx.edges.filter(candidate => candidate.source === edge.source &&
726
- candidate.edgeIndex !== undefined &&
727
- ctx.classes?.[candidate.edgeIndex] === 'primary-forward' &&
728
- nodeMap.has(candidate.target))
729
- if (!diamondSpreadPortSet(axis, siblings.length)) return false
730
- const targetMains = siblings.map(candidate => {
731
- const target = nodeMap.get(candidate.target)!
732
- return target[axis.main] + (axis.main === 'x' ? target.width : target.height) / 2
733
- })
734
- return Math.max(...targetMains) - Math.min(...targetMains) <= 1
735
- }
736
-
737
- interface DiamondSpreadTargetPortConstraint {
738
- sourcePort: PortSide | DiamondFacet
739
- sourceLane: number
740
- targetPort: PortSide
741
- targetLane: number
742
- }
743
-
744
- function diamondSpreadTargetPortConstraint(
745
- edge: PositionedEdge,
746
- source: PositionedNode,
747
- target: PositionedNode,
748
- ctx: LaneContext,
749
- axis: Axis,
750
- ): DiamondSpreadTargetPortConstraint | null {
751
- const spread = diamondSpreadAssignment(edge, source, ctx, axis)
752
- if (!spread || !diamondSpreadTargetsShareRank(edge, source, ctx, axis)) return null
753
- const targetPort = facingSide(axis, -1)
754
- const targetLane = shapePorts(target)[targetPort][axis.cross]
755
- if (Math.abs(spread.sourceLane - targetLane) <= PORT_TOLERANCE) return null
756
- return { ...spread, targetPort, targetLane }
757
- }
758
-
759
- function satisfiesDiamondSpreadTargetPortConstraint(
760
- edge: PositionedEdge,
761
- source: PositionedNode,
762
- target: PositionedNode,
763
- constraint: DiamondSpreadTargetPortConstraint,
764
- ): boolean {
765
- if (edge.points.length <= 2) return false
766
- const start = edge.points[0]!
767
- const end = edge.points[edge.points.length - 1]!
768
- const sourcePort = portPoint(source, constraint.sourcePort)
769
- const targetPort = shapePorts(target)[constraint.targetPort]
770
- return Math.abs(start.x - sourcePort.x) <= PORT_TOLERANCE &&
771
- Math.abs(start.y - sourcePort.y) <= PORT_TOLERANCE &&
772
- Math.abs(end.x - targetPort.x) <= PORT_TOLERANCE &&
773
- Math.abs(end.y - targetPort.y) <= PORT_TOLERANCE
774
- }
775
-
776
- /**
777
- * Primary edges own the straight lane (spec §5): when proving a primary
778
- * lane, the reciprocal feedback partner's label is movable decoration —
779
- * the partner re-places it when it straightens, or keeps it on its detour —
780
- * so it must not veto the primary's lane.
781
- */
782
- function isMovableReciprocalLabel(edge: PositionedEdge, other: PositionedEdge, ctx: LaneContext): boolean {
783
- if (!ctx.classes || edge.edgeIndex === undefined || other.edgeIndex === undefined) return false
784
- return ctx.classes[edge.edgeIndex] === 'primary-forward' &&
785
- ctx.classes[other.edgeIndex] === 'feedback' &&
786
- other.source === edge.target && other.target === edge.source
787
- }
788
-
789
- function edgeId(e: { source: string; target: string }): string {
790
- return `${e.source}->${e.target}`
791
- }
792
-
793
- /** The rendered pill rect for a label of this size centered at (cx, cy). */
794
- function pillRect(cx: number, cy: number, m: { width: number; height: number }): { x: number; y: number; w: number; h: number } {
795
- const w = m.width + 2 * LABEL_PILL_PADDING
796
- const h = m.height + 2 * LABEL_PILL_PADDING
797
- return { x: cx - w / 2, y: cy - h / 2, w, h }
798
- }
799
-
800
- export function labelRect(e: PositionedEdge, style: LabelMetricsStyle): { x: number; y: number; w: number; h: number } | null {
801
- if (!e.label || !e.labelPosition) return null
802
- const m = measureMultilineText(applyTextTransform(e.label, style.edgeTextTransform), style.edgeLabelFontSize, style.edgeLabelFontWeight)
803
- return pillRect(e.labelPosition.x, e.labelPosition.y, m)
804
- }
805
-
806
- /**
807
- * Prove a straight lane at cross-coordinate `c`, spanning [mainLo, mainHi] on
808
- * the flow axis, clear for `edge`. Returns the blockers ([] = proof succeeded).
809
- */
810
- export function directLaneBlockers(
811
- edge: PositionedEdge,
812
- c: number,
813
- mainLo: number,
814
- mainHi: number,
815
- ctx: LaneContext,
816
- axis: Axis = ctx.axis,
817
- ): RouteBlocker[] {
818
- const blockers: RouteBlocker[] = []
819
- const overlaps = (lo: number, hi: number, lo2: number, hi2: number) =>
820
- Math.min(hi, hi2) - Math.max(lo, lo2) > EPS
821
-
822
- for (const node of ctx.nodes) {
823
- if (node.id === edge.source || node.id === edge.target) continue
824
- const nMainLo = node[axis.main] - CLEARANCE
825
- const nMainHi = node[axis.main] + (axis.main === 'x' ? node.width : node.height) + CLEARANCE
826
- const nCrossLo = node[axis.cross] - CLEARANCE
827
- const nCrossHi = node[axis.cross] + (axis.cross === 'y' ? node.height : node.width) + CLEARANCE
828
- if (c > nCrossLo && c < nCrossHi && overlaps(mainLo, mainHi, nMainLo, nMainHi)) {
829
- blockers.push({ kind: 'node', id: node.id })
830
- }
831
- }
832
-
833
- for (const other of ctx.edges) {
834
- // Compare by edgeIndex as well as identity: validation proves lanes on a
835
- // copy of the edge, and the original must not block its own lane.
836
- if (other === edge || (edge.edgeIndex !== undefined && other.edgeIndex === edge.edgeIndex)) continue
837
- const rect = isMovableReciprocalLabel(edge, other, ctx) ? null : labelRect(other, ctx.style)
838
- if (rect) {
839
- const rMainLo = (axis.main === 'x' ? rect.x : rect.y) - CLEARANCE
840
- const rMainHi = rMainLo + (axis.main === 'x' ? rect.w : rect.h) + 2 * CLEARANCE
841
- const rCrossLo = (axis.cross === 'y' ? rect.y : rect.x) - CLEARANCE
842
- const rCrossHi = rCrossLo + (axis.cross === 'y' ? rect.h : rect.w) + 2 * CLEARANCE
843
- if (c > rCrossLo && c < rCrossHi && overlaps(mainLo, mainHi, rMainLo, rMainHi)) {
844
- blockers.push({ kind: 'label', id: edgeId(other) })
845
- }
846
- }
847
- // Collinear overlap with another edge's flow-axis segment would merge the
848
- // two lines visually ("channel" conflict). Perpendicular crossings are
849
- // fine. Exception: two edges into the SAME target may converge exactly
850
- // collinearly at the shared target port — the classic flowchart fan-in
851
- // merge, where the final approaches draw as one line into one arrowhead.
852
- for (let i = 1; i < other.points.length; i++) {
853
- const a = other.points[i - 1]!, b = other.points[i]!
854
- if (Math.abs(a[axis.cross] - b[axis.cross]) > EPS) continue
855
- if (Math.abs(a[axis.cross] - c) >= CLEARANCE) continue
856
- const sLo = Math.min(a[axis.main], b[axis.main])
857
- const sHi = Math.max(a[axis.main], b[axis.main])
858
- if (overlaps(mainLo, mainHi, sLo, sHi)) {
859
- if (other.target === edge.target && other.source !== edge.source &&
860
- Math.abs(a[axis.cross] - c) < EPS && i === other.points.length - 1) {
861
- continue
862
- }
863
- blockers.push({ kind: 'channel', id: edgeId(other) })
864
- break
865
- }
866
- }
867
- }
868
-
869
- if (edge.label) {
870
- const m = measureMultilineText(applyTextTransform(edge.label, ctx.style.edgeTextTransform), ctx.style.edgeLabelFontSize, ctx.style.edgeLabelFontWeight)
871
- const span = (axis.main === 'x' ? m.width : m.height) + 2 * LABEL_PILL_PADDING
872
- if (mainHi - mainLo < span + LABEL_CLEARANCE) {
873
- blockers.push({ kind: 'label', id: edgeId(edge) })
874
- }
875
- }
876
-
877
- return dedupeBlockers(blockers)
878
- }
879
-
880
- /**
881
- * Find a position ON the straight lane where this edge's label can sit
882
- * without overlapping nodes, other edges' labels, or other edges' segments
883
- * (spec §11.4: labels are obstacles for each other). Tries the midpoint,
884
- * then 1/3 and 2/3, so parallel labeled pairs with room stagger their
885
- * labels. On-lane only: a label must sit on its own route so the
886
- * label-to-edge association stays unambiguous (Kakoulis–Tollis); an edge
887
- * whose lane cannot host its label does not straighten — with ELK's
888
- * feedbackEdges routing it loops through an outer channel instead, where
889
- * the inline label dummy has reserved space. Returns null when no slot is
890
- * clear.
891
- */
892
- export function findLabelSlot(
893
- edge: PositionedEdge,
894
- start: Point,
895
- end: Point,
896
- ctx: LaneContext,
897
- ): Point | null {
898
- if (!edge.label) return { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }
899
- const m = measureMultilineText(applyTextTransform(edge.label, ctx.style.edgeTextTransform), ctx.style.edgeLabelFontSize, ctx.style.edgeLabelFontWeight)
900
- const PAD = 2
901
- const rectsOverlap = (ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number) =>
902
- ax < bx + bw + PAD && ax + aw + PAD > bx && ay < by + bh + PAD && ay + ah + PAD > by
903
-
904
- const slotClear = (cx: number, cy: number): boolean => {
905
- const pill = pillRect(cx, cy, m)
906
- for (const node of ctx.nodes) {
907
- if (node.id === edge.source || node.id === edge.target) continue
908
- if (rectsOverlap(pill.x, pill.y, pill.w, pill.h, node.x, node.y, node.width, node.height)) return false
909
- }
910
- for (const other of ctx.edges) {
911
- if (other === edge || (edge.edgeIndex !== undefined && other.edgeIndex === edge.edgeIndex)) continue
912
- const rect = isMovableReciprocalLabel(edge, other, ctx) ? null : labelRect(other, ctx.style)
913
- if (rect && rectsOverlap(pill.x, pill.y, pill.w, pill.h, rect.x, rect.y, rect.w, rect.h)) return false
914
- for (let i = 1; i < other.points.length; i++) {
915
- const a = other.points[i - 1]!, b = other.points[i]!
916
- const sxLo = Math.min(a.x, b.x), sxHi = Math.max(a.x, b.x)
917
- const syLo = Math.min(a.y, b.y), syHi = Math.max(a.y, b.y)
918
- if (rectsOverlap(pill.x, pill.y, pill.w, pill.h, sxLo, syLo, sxHi - sxLo, syHi - syLo)) return false
919
- }
920
- }
921
- return true
922
- }
923
-
924
- // Discrete candidate positions along the lane (Kakoulis–Tollis candidate
925
- // sets), center-out: the midpoint reads best, the outer slots rescue lanes
926
- // whose middle band is occupied by a neighboring node or pill.
927
- for (const t of [0.5, 1 / 3, 2 / 3, 0.25, 0.75, 1 / 6, 5 / 6]) {
928
- const cx = start.x + (end.x - start.x) * t
929
- const cy = start.y + (end.y - start.y) * t
930
- if (slotClear(cx, cy)) return { x: cx, y: cy }
931
- }
932
- return null
933
- }
934
-
935
- /** Strict-interior intersection of two segments (touching endpoints don't count). */
936
- function segmentsIntersect(a: Point, b: Point, c: Point, d: Point): boolean {
937
- const det = (b.x - a.x) * (d.y - c.y) - (b.y - a.y) * (d.x - c.x)
938
- if (Math.abs(det) < 1e-9) return false
939
- const t = ((c.x - a.x) * (d.y - c.y) - (c.y - a.y) * (d.x - c.x)) / det
940
- const u = ((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / det
941
- return t > 0.001 && t < 0.999 && u > 0.001 && u < 0.999
942
- }
943
-
944
- /** Crossings between a candidate route and every other edge's segments. */
945
- function countRouteCrossings(points: Point[], edge: PositionedEdge, ctx: LaneContext): number {
946
- let count = 0
947
- for (const other of ctx.edges) {
948
- if (other === edge || (edge.edgeIndex !== undefined && other.edgeIndex === edge.edgeIndex)) continue
949
- for (let i = 1; i < points.length; i++) {
950
- for (let j = 1; j < other.points.length; j++) {
951
- if (segmentsIntersect(points[i - 1]!, points[i]!, other.points[j - 1]!, other.points[j]!)) count++
952
- }
953
- }
954
- }
955
- return count
956
- }
957
-
958
- /**
959
- * Conservative check that a route passes through some non-incident node's
960
- * bbox interior — the signature of a stale path after a later pass moved a
961
- * node into an already-routed corridor (issue #25 rule 9). Used only to
962
- * TRIGGER a proof-gated repair; the repair's own checks are shape-exact.
963
- */
964
- function routeThroughNodeBBox(edge: PositionedEdge, ctx: LaneContext): boolean {
965
- for (const node of ctx.nodes) {
966
- if (node.id === edge.source || node.id === edge.target) continue
967
- for (let i = 1; i < edge.points.length; i++) {
968
- const a = edge.points[i - 1]!, b = edge.points[i]!
969
- const xLo = Math.min(a.x, b.x), xHi = Math.max(a.x, b.x)
970
- const yLo = Math.min(a.y, b.y), yHi = Math.max(a.y, b.y)
971
- if (xHi > node.x + 0.5 && xLo < node.x + node.width - 0.5 &&
972
- yHi > node.y + 0.5 && yLo < node.y + node.height - 0.5) {
973
- return true
974
- }
975
- }
976
- }
977
- return false
978
- }
979
-
980
- function dedupeBlockers(blockers: RouteBlocker[]): RouteBlocker[] {
981
- const seen = new Set<string>()
982
- return blockers.filter(b => {
983
- const key = `${b.kind}:${b.id}`
984
- if (seen.has(key)) return false
985
- seen.add(key)
986
- return true
987
- })
988
- }
989
-
990
- // ============================================================================
991
- // Certifying straightener
992
- // ============================================================================
993
-
994
- interface StraightenAttempt {
995
- applied: boolean
996
- blockers: RouteBlocker[]
997
- /** Which candidate lane won (0 = the preferred one). */
998
- usedCandidateIndex?: number
999
- }
1000
-
1001
- /**
1002
- * Try to collapse one monotone staircase to a straight lane. Candidate lanes
1003
- * are tried in order: the target endpoint's cross-coordinate (moves only the
1004
- * source end), the source endpoint's (moves only the target end), the
1005
- * extremes of the existing route (the channel the router already proved
1006
- * navigable — how a feedback edge wrapping a node collapses onto its outer
1007
- * channel), then the center of the two attachment spans' overlap. The first
1008
- * candidate that lies in both spans and proves clear wins.
1009
- *
1010
- * `axis` carries the edge's own flow direction: the graph axis for
1011
- * primary-forward edges, the sign-flipped axis for feedback edges (their
1012
- * forward-facing side is the graph's backward-facing one).
1013
- */
1014
- /** The cross-lane geometry a straightening attempt works within: the two
1015
- * attachment spans' overlap, the diamond-relaxed overlap that port lanes may
1016
- * use, the ordered candidate lanes, and the port-lane predicate. Shared by the
1017
- * mutating straightener (tryStraighten) and the read-only predictor
1018
- * (straightLaneFor) so both rank lanes identically. `null` when no lane can
1019
- * attach (a span is missing or the relaxed overlap is empty). */
1020
- interface LaneSearch {
1021
- overlapLo: number
1022
- overlapHi: number
1023
- relaxedLo: number
1024
- relaxedHi: number
1025
- candidates: number[]
1026
- isPortLane: (c: number) => boolean
1027
- emptyRelaxed: boolean
1028
- }
1029
-
1030
- function laneSearch(
1031
- edge: PositionedEdge,
1032
- source: PositionedNode,
1033
- target: PositionedNode,
1034
- ctx: LaneContext,
1035
- axis: Axis,
1036
- restrictToLane?: number,
1037
- ): LaneSearch | null {
1038
- const srcSpan = attachSpan(source, axis)
1039
- const tgtSpan = attachSpan(target, axis)
1040
- if (!srcSpan || !tgtSpan) return null
1041
-
1042
- const overlapLo = Math.max(srcSpan.lo, tgtSpan.lo)
1043
- const overlapHi = Math.min(srcSpan.hi, tgtSpan.hi)
1044
- // Port lanes (through a side-midpoint port) may attach the OTHER endpoint
1045
- // closer to a diamond vertex than the aesthetic margin normally allows:
1046
- // exact connection points outrank the margin (yFiles port-candidate costs).
1047
- const relax = (node: PositionedNode, span: CrossSpan): CrossSpan => {
1048
- if (node.shape !== 'diamond') return span
1049
- return { lo: node[axis.cross] + 2, hi: node[axis.cross] + (axis.cross === 'y' ? node.height : node.width) - 2 }
1050
- }
1051
- const relaxedLo = Math.max(relax(source, srcSpan).lo, relax(target, tgtSpan).lo)
1052
- const relaxedHi = Math.min(relax(source, srcSpan).hi, relax(target, tgtSpan).hi)
1053
- const portCross = (node: PositionedNode) => node[axis.cross] + (axis.cross === 'y' ? node.height : node.width) / 2
1054
- const isPortLane = (c: number) =>
1055
- Math.abs(c - portCross(target)) <= PORT_TOLERANCE || Math.abs(c - portCross(source)) <= PORT_TOLERANCE
1056
- if (relaxedLo > relaxedHi) {
1057
- return { overlapLo, overlapHi, relaxedLo, relaxedHi, candidates: [], isPortLane, emptyRelaxed: true }
1058
- }
1059
-
1060
- const candidates: number[] = []
1061
- const push = (c: number) => {
1062
- if (candidates.every(prev => Math.abs(prev - c) > 0.5)) candidates.push(c)
1063
- }
1064
- // Port lanes first (static glue). Their ORDER is the port ranking: when
1065
- // the source is a diamond whose exit side carries exactly ONE line, its
1066
- // vertex is the cheapest anchor and lines should EMIT from the sharp bit
1067
- // (sharper shape wins; source wins ties). A side carrying several lines
1068
- // spreads instead — no line hogs the point — and fan-ins still prefer the
1069
- // target port, where same-target edges merge. Remaining candidates are
1070
- // dynamic glue: straight, but floating on the side/facet.
1071
- const tgtPortLane = target[axis.cross] + (axis.cross === 'y' ? target.height : target.width) / 2
1072
- const srcPortLane = source[axis.cross] + (axis.cross === 'y' ? source.height : source.width) / 2
1073
- const srcSideUse = ctx.sideUse?.get(`${source.id}:${facingSide(axis, 1)}`) ?? 1
1074
- const emitFromVertex = source.shape === 'diamond' && srcSideUse === 1 &&
1075
- portSharpness(source.shape) >= portSharpness(target.shape)
1076
- // A reciprocal pair (A->B with an unlabeled B->A partner) renders as TWO
1077
- // EQUAL parallel lines straddling the centerline at center ± SEP/2 —
1078
- // equal deviation gives equal lengths (a diamond's facet width depends on
1079
- // the lane's distance from the vertex), the way dot offsets reciprocal
1080
- // splines symmetrically about the spine. The primary takes the low side,
1081
- // the feedback the high side: deterministic and never colliding.
1082
- const reciprocal = ctx.classes && ctx.edges.some(other =>
1083
- other !== edge && other.source === edge.target && other.target === edge.source &&
1084
- !other.label && !edge.label &&
1085
- other.edgeIndex !== undefined && edge.edgeIndex !== undefined)
1086
- // A reciprocal pair BETWEEN TWO DIAMONDS (G) parts at the NEAREST facet-mids
1087
- // rather than the generic center ± SEP/2: the two parallel lines run between
1088
- // the diamonds' facets (never through a vertex). The primary takes the
1089
- // upper facet (NE source-mid for an LR exit), the feedback the lower (SW);
1090
- // the cross offset is the source diamond's own facet-mid offset (size/4), so
1091
- // its endpoint lands exactly on a facet-mid. The target end anchors on its
1092
- // facing facet at the same lane (on-outline, the partner's nearest mid).
1093
- const recipDiamond = reciprocal && source.shape === 'diamond' && target.shape === 'diamond'
1094
- if (restrictToLane !== undefined) {
1095
- candidates.push(restrictToLane)
1096
- } else if (recipDiamond) {
1097
- const own = ctx.classes![edge.edgeIndex!] === 'feedback' ? 1 : -1
1098
- const srcCenter = source[axis.cross] + (axis.cross === 'y' ? source.height : source.width) / 2
1099
- const srcFacetOffset = (axis.cross === 'y' ? source.height : source.width) / 4
1100
- push(srcCenter + own * srcFacetOffset)
1101
- push((overlapLo + overlapHi) / 2 + own * PAIR_SEPARATION / 2)
1102
- push(tgtPortLane)
1103
- push(srcPortLane)
1104
- } else if (reciprocal) {
1105
- const pairCenter = (overlapLo + overlapHi) / 2
1106
- const own = ctx.classes![edge.edgeIndex!] === 'feedback' ? 1 : -1
1107
- push(pairCenter + own * PAIR_SEPARATION / 2)
1108
- push(tgtPortLane)
1109
- push(srcPortLane)
1110
- } else if (emitFromVertex) {
1111
- push(srcPortLane)
1112
- push(tgtPortLane)
1113
- } else {
1114
- const spreadLane = diamondSpreadLane(edge, source, ctx, axis)
1115
- if (spreadLane !== undefined) push(spreadLane)
1116
- push(tgtPortLane)
1117
- push(srcPortLane)
1118
- }
1119
- if (restrictToLane === undefined) {
1120
- push(edge.points[edge.points.length - 1]![axis.cross])
1121
- push(edge.points[0]![axis.cross])
1122
- const crossValues = edge.points.map(p => p[axis.cross])
1123
- push(Math.min(...crossValues))
1124
- push(Math.max(...crossValues))
1125
- push((overlapLo + overlapHi) / 2)
1126
- // Span quartiles: an unlabeled feedback loop's own cross values all sit
1127
- // on the forward lane or outside the span, so the parallel back-lane
1128
- // that collapses it to the classic two-arrow rendering lies between the
1129
- // span center and a span end.
1130
- push((overlapLo + (overlapLo + overlapHi) / 2) / 2)
1131
- push(((overlapLo + overlapHi) / 2 + overlapHi) / 2)
1132
- }
1133
-
1134
- return { overlapLo, overlapHi, relaxedLo, relaxedHi, candidates, isPortLane, emptyRelaxed: false }
1135
- }
1136
-
1137
- /** Test ONE candidate lane `c` for a straight `source`->`target` route. Returns
1138
- * the proven straight endpoints when the lane is in span, monotone, obstacle-
1139
- * clear, crossing-free, and label-hostable; otherwise the blocker(s) that
1140
- * rejected it. Pure — never mutates the edge — so both the mutating
1141
- * straightener and the read-only predictor share one lane proof. */
1142
- function proveLane(
1143
- edge: PositionedEdge,
1144
- source: PositionedNode,
1145
- target: PositionedNode,
1146
- c: number,
1147
- ctx: LaneContext,
1148
- axis: Axis,
1149
- search: LaneSearch,
1150
- ): { start: Point; end: Point; slot: Point | null } | { blockers: RouteBlocker[] } {
1151
- const lo = search.isPortLane(c) ? search.relaxedLo : search.overlapLo
1152
- const hi = search.isPortLane(c) ? search.relaxedHi : search.overlapHi
1153
- if (c < lo - EPS || c > hi + EPS) return { blockers: [{ kind: 'span', id: edgeId(edge) }] }
1154
- const srcMain = anchorMain(source, c, axis, 1)
1155
- const tgtMain = anchorMain(target, c, axis, -1)
1156
- if ((tgtMain - srcMain) * axis.sign <= EPS) return { blockers: [{ kind: 'span', id: edgeId(edge) }] }
1157
- const mainLo = Math.min(srcMain, tgtMain)
1158
- const mainHi = Math.max(srcMain, tgtMain)
1159
- const found = directLaneBlockers(edge, c, mainLo, mainHi, ctx, axis)
1160
- if (found.length > 0) return { blockers: found }
1161
- const start: Point = axis.main === 'x' ? { x: srcMain, y: c } : { x: c, y: srcMain }
1162
- const end: Point = axis.main === 'x' ? { x: tgtMain, y: c } : { x: c, y: tgtMain }
1163
- // A repair may never increase edge crossings: a perpendicular crossing is
1164
- // legal when the router chose it, but the straightener must not create
1165
- // one that the original route avoided.
1166
- if (countRouteCrossings([start, end], edge, ctx) > countRouteCrossings(edge.points, edge, ctx)) {
1167
- return { blockers: [{ kind: 'crossing', id: edgeId(edge) }] }
1168
- }
1169
- const slot = findLabelSlot(edge, start, end, ctx)
1170
- if (slot === null) return { blockers: [{ kind: 'label', id: edgeId(edge) }] }
1171
- return { start, end, slot }
1172
- }
1173
-
1174
- function tryStraighten(
1175
- edge: PositionedEdge,
1176
- source: PositionedNode,
1177
- target: PositionedNode,
1178
- ctx: LaneContext,
1179
- axis: Axis = ctx.axis,
1180
- restrictToLane?: number,
1181
- ): StraightenAttempt {
1182
- const search = laneSearch(edge, source, target, ctx, axis, restrictToLane)
1183
- if (!search) return { applied: false, blockers: [] }
1184
- if (search.emptyRelaxed) return { applied: false, blockers: [{ kind: 'span', id: edgeId(edge) }] }
1185
-
1186
- const blockers: RouteBlocker[] = []
1187
- for (let candidateIndex = 0; candidateIndex < search.candidates.length; candidateIndex++) {
1188
- const c = search.candidates[candidateIndex]!
1189
- const proven = proveLane(edge, source, target, c, ctx, axis, search)
1190
- if ('blockers' in proven) {
1191
- blockers.push(...proven.blockers)
1192
- continue
1193
- }
1194
- edge.points = [proven.start, proven.end]
1195
- if (edge.label) edge.labelPosition = proven.slot ?? undefined
1196
- return { applied: true, blockers: [], usedCandidateIndex: candidateIndex }
1197
- }
1198
- return { applied: false, blockers: dedupeBlockers(blockers) }
1199
- }
1200
-
1201
- /**
1202
- * Read-only predictor: the cross-lane `tryStraighten` would settle this edge
1203
- * onto for the current node positions, or `null` when no straight lane proves
1204
- * clear (the edge stays a staircase). Runs the SAME ranked candidate search and
1205
- * per-lane proof as `tryStraighten` but mutates nothing — so a placement pass
1206
- * can move the source onto the lane the straightener will use, BEFORE the
1207
- * freeze, and have the exit land on the moved source's mid-port instead of the
1208
- * straightener pulling it off. Restricted to primary-forward edges (the class
1209
- * the placement passes repair); feedback/diamond-spread ranking is unchanged.
1210
- */
1211
- export function straightLaneFor(
1212
- edge: PositionedEdge,
1213
- source: PositionedNode,
1214
- target: PositionedNode,
1215
- ctx: LaneContext,
1216
- axis: Axis = ctx.axis,
1217
- ): number | null {
1218
- const search = laneSearch(edge, source, target, ctx, axis)
1219
- if (!search || search.emptyRelaxed) return null
1220
- if (!isStraightenable(source.shape) || !isStraightenable(target.shape)) return null
1221
- if (!isMonotoneStaircase(edge.points, axis)) return null
1222
- for (const c of search.candidates) {
1223
- const proven = proveLane(edge, source, target, c, ctx, axis, search)
1224
- if (!('blockers' in proven)) return c
1225
- }
1226
- return null
1227
- }
1228
-
1229
- /** A minimal LaneContext for the read-only predictor. Omits the port-ranking
1230
- * occupancy (sideUse) and route classes the mutating pass derives — the
1231
- * predictor treats every foreign label as an obstacle (conservative) and never
1232
- * needs the diamond-vertex emit ranking, which the placement passes don't move
1233
- * onto. Exported so the placement passes can predict lanes without rebuilding
1234
- * the route-class/port-allocation machinery. */
1235
- export function laneContextFor(
1236
- nodes: PositionedNode[],
1237
- edges: PositionedEdge[],
1238
- direction: Direction,
1239
- style: LabelMetricsStyle,
1240
- ): { ctx: LaneContext; axis: Axis } {
1241
- const axis = axisFor(direction)
1242
- return { ctx: { nodes, edges, axis, style }, axis }
1243
- }
1244
-
1245
- /**
1246
- * The route-contract pass: simplify every polyline (proof-free), straighten
1247
- * primary-forward and feedback staircases whose direct lane proves clear
1248
- * (proof-carrying), and certify every edge. Edges are processed in author
1249
- * order, and the pass iterates to a fixed point: straightening one edge can
1250
- * vacate the channel that blocked a sibling (duplicate parallel edges do
1251
- * exactly this), so blocked edges are re-proved until nothing changes.
1252
- * Attaches the certificate to the edge and returns all certificates.
1253
- */
1254
- export function applyRouteContracts(
1255
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[]; groups: PositionedGroup[] },
1256
- graph: MermaidGraph,
1257
- bundled: ReadonlySet<PositionedEdge>,
1258
- style: LabelMetricsStyle,
1259
- ): RouteCertificate[] {
1260
- const classes = classifyRoutes(graph)
1261
- const axis = axisFor(graph.direction)
1262
- const nodeMap = new Map(positioned.nodes.map(n => [n.id, n]))
1263
- const groupMap = flattenGroups(positioned.groups)
1264
- // Port-ranking occupancy: how many lines each facing side will carry.
1265
- // Derived by the dynamic port allocator, but intentionally counted on the
1266
- // semantic facing side (primary flow sides; unlabeled feedback flipped
1267
- // sides) so routing behavior stays independent of the current, possibly
1268
- // not-yet-repaired endpoint coordinates.
1269
- const sideUse = allocateRoutePorts(positioned, graph, classes).sideUse
1270
- for (const edge of bundled) {
1271
- if (edge.edgeIndex === undefined) continue
1272
- const cls = classes[edge.edgeIndex]
1273
- if (cls !== 'primary-forward' && !(cls === 'feedback' && !edge.label)) continue
1274
- const sourceSide = cls === 'feedback'
1275
- ? facingSide({ ...axis, sign: axis.sign === 1 ? -1 : 1 }, 1)
1276
- : facingSide(axis, 1)
1277
- const targetSide = cls === 'feedback'
1278
- ? facingSide({ ...axis, sign: axis.sign === 1 ? -1 : 1 }, -1)
1279
- : facingSide(axis, -1)
1280
- for (const [nodeId, side] of [[edge.source, sourceSide], [edge.target, targetSide]] as const) {
1281
- const key = `${nodeId}:${side}`
1282
- const next = (sideUse.get(key) ?? 0) - 1
1283
- if (next > 0) sideUse.set(key, next)
1284
- else sideUse.delete(key)
1285
- }
1286
- }
1287
- const ctx: LaneContext = { nodes: positioned.nodes, edges: positioned.edges, axis, style, classes, sideUse }
1288
- const drafts: Array<{ edge: PositionedEdge; cert: DraftRouteCertificate }> = []
1289
-
1290
- // Attempt one proof-carrying straightening; updates geometry + certificate.
1291
- // `applied` means the route is straight; `mutated` means geometry moved this
1292
- // call; `upgradeable` means a non-preferred lane won, so the edge stays in
1293
- // the fixed-point pool — a later repair may free the preferred lane (e.g.
1294
- // the symmetric lane of a reciprocal pair blocked by its partner's
1295
- // not-yet-repaired geometry).
1296
- const attemptStraighten = (
1297
- edge: PositionedEdge,
1298
- cert: DraftRouteCertificate,
1299
- ): { applied: boolean; mutated: boolean; upgradeable?: boolean } => {
1300
- const edgeAxis: Axis = cert.routeClass === 'feedback' ? { ...axis, sign: axis.sign === 1 ? -1 : 1 } : axis
1301
- const source = nodeMap.get(edge.source)
1302
- const target = nodeMap.get(edge.target)
1303
- // Feedback loops from ELK's feedbackEdges routing start by moving AWAY
1304
- // from the target (around the nodes), so they are never monotone; the
1305
- // lane proof alone gates whether they may collapse onto a parallel
1306
- // back-lane. Primary edges keep the monotone gate.
1307
- const eligible = source && target &&
1308
- isStraightenable(source.shape) &&
1309
- isStraightenable(target.shape) &&
1310
- (cert.routeClass === 'feedback' || isMonotoneStaircase(edge.points, edgeAxis))
1311
- if (!eligible) {
1312
- cert.invariant = feedbackDetourKind(edge, cert.routeClass, source, target, ctx.axis) ?? 'unverified-shape'
1313
- return { applied: false, mutated: false }
1314
- }
1315
- // Port ranking, hard case: a diamond side carrying exactly ONE line
1316
- // always emits from its vertex (the user-facing convention dot and
1317
- // yFiles follow). If no straight lane through the vertex exists, a
1318
- // 2-bend Z from the vertex into the target's port still outranks a
1319
- // straight lane that floats off the vertex.
1320
- const srcSideUse = ctx.sideUse?.get(`${source!.id}:${facingSide(edgeAxis, 1)}`) ?? 1
1321
- const emitFromVertex = cert.routeClass === 'primary-forward' &&
1322
- source!.shape === 'diamond' && srcSideUse === 1 &&
1323
- portSharpness(source!.shape) >= portSharpness(target!.shape)
1324
- const beforeGeometry = JSON.stringify(edge.points)
1325
- if (emitFromVertex) {
1326
- const vertexLane = source![edgeAxis.cross] + (edgeAxis.cross === 'y' ? source!.height : source!.width) / 2
1327
- const direct = tryStraighten(edge, source!, target!, ctx, edgeAxis, vertexLane)
1328
- if (direct.applied) {
1329
- const mutated = JSON.stringify(edge.points) !== beforeGeometry
1330
- cert.invariant = 'straight'
1331
- cert.bendCount = 0
1332
- cert.directLaneClear = true
1333
- if (mutated) cert.straightened = true
1334
- cert.directLaneBlockedBy = undefined
1335
- return { applied: true, mutated }
1336
- }
1337
- // Next-best after the straight vertex lane: the 1-bend hook into the
1338
- // target's facing cross-side port; the 2-bend Z into the flow-side
1339
- // port only when no hook exists.
1340
- if (tryVertexHook(edge, source!, target!, ctx, edgeAxis) ||
1341
- tryZRoute(edge, source!, target!, ctx, edgeAxis, true)) {
1342
- cert.invariant = 'explained-detour'
1343
- cert.directLaneClear = false
1344
- cert.directLaneBlockedBy = dedupeBlockers(direct.blockers)
1345
- cert.bendCount = bendCount(edge.points)
1346
- // applied=false keeps the edge in the fixed-point retry pool: if a
1347
- // later repair clears the straight vertex lane (a blocking pill
1348
- // moves), the hook/Z upgrades to the straight emit on the next round.
1349
- return { applied: false, mutated: JSON.stringify(edge.points) !== beforeGeometry }
1350
- }
1351
- }
1352
- const portConstraint = diamondSpreadTargetPortConstraint(edge, source!, target!, ctx, edgeAxis)
1353
- if (portConstraint &&
1354
- tryZRoute(edge, source!, target!, ctx, edgeAxis, false, {
1355
- sourceLane: portConstraint.sourceLane,
1356
- targetLane: portConstraint.targetLane,
1357
- preferSourceJog: true,
1358
- })) {
1359
- cert.invariant = 'explained-detour'
1360
- cert.directLaneClear = false
1361
- cert.directLaneBlockedBy = [{ kind: 'port', id: edgeId(edge) }]
1362
- cert.bendCount = bendCount(edge.points)
1363
- return { applied: false, mutated: JSON.stringify(edge.points) !== beforeGeometry }
1364
- }
1365
- const attempt = tryStraighten(edge, source!, target!, ctx, edgeAxis)
1366
- if (attempt.applied) {
1367
- const mutated = JSON.stringify(edge.points) !== beforeGeometry
1368
- cert.invariant = 'straight'
1369
- cert.bendCount = 0
1370
- cert.directLaneClear = true
1371
- if (mutated) cert.straightened = true
1372
- cert.directLaneBlockedBy = undefined
1373
- return { applied: true, mutated, upgradeable: (attempt.usedCandidateIndex ?? 0) > 0 }
1374
- }
1375
- cert.invariant = feedbackDetourKind(edge, cert.routeClass, source, target, ctx.axis) ?? 'explained-detour'
1376
- cert.directLaneClear = false
1377
- cert.directLaneBlockedBy = attempt.blockers
1378
- // Geometry mutations below (loop tightening, Z-repair) must drive the
1379
- // fixed-point retry loop: a tightened loop can vacate the lane that
1380
- // blocked a sibling, exactly like a straightening can.
1381
- let mutated = false
1382
- if (cert.invariant === 'outer-feedback' || cert.invariant === 'feedback-detour') {
1383
- if (cert.invariant === 'outer-feedback') {
1384
- mutated = tightenOuterFeedback(edge, ctx)
1385
- cert.bendCount = bendCount(edge.points)
1386
- }
1387
- // A loop that passes THROUGH a node (a later pass moved the node into
1388
- // the routed corridor) is repaired like any stale route: a reverse-axis
1389
- // Z through proven-clear lanes, or the loop stays as the explained
1390
- // fallback.
1391
- if (routeThroughNodeBBox(edge, ctx) && tryZRoute(edge, source!, target!, ctx, edgeAxis)) {
1392
- cert.bendCount = bendCount(edge.points)
1393
- mutated = true
1394
- }
1395
- } else if (cert.invariant === 'explained-detour' &&
1396
- (cert.bendCount > 2 || routeThroughNodeBBox(edge, ctx) ||
1397
- !portAt(source!, edge.points[0]!) ||
1398
- !portAt(target!, edge.points[edge.points.length - 1]!))) {
1399
- // Bend minimization (the Tamassia tradition): when the lane is
1400
- // genuinely blocked, a 2-bend Z that terminates on the target's port
1401
- // still beats ELK's multi-bend staircase. The same repair applies when
1402
- // either endpoint has drifted off its rendered outline. Also triggered
1403
- // whenever the kept route passes through a node — the stale-corridor
1404
- // signature of a later pass moving a node into an already-routed channel.
1405
- // Proof-gated like everything else; failure keeps the explained route.
1406
- // When even the Z can't clear the obstacle (the source's whole flow side
1407
- // is shadowed), the 3-bend sidestep detour is the last resort — it steps
1408
- // clear of the obstacle band through a proven gap. Both are proof-gated;
1409
- // failure keeps the explained route.
1410
- if (tryZRoute(edge, source!, target!, ctx, edgeAxis) ||
1411
- tryEscapeDetour(edge, source!, target!, ctx, edgeAxis)) {
1412
- cert.bendCount = bendCount(edge.points)
1413
- mutated = true
1414
- }
1415
- }
1416
- return { applied: false, mutated }
1417
- }
1418
-
1419
- /**
1420
- * Try to replace a blocked edge's multi-bend route with a single Z:
1421
- * lane at c1 from the source → perpendicular hop at `jog` → lane at c2
1422
- * into the target. Candidates prefer the ports (c2 = target port, c1 =
1423
- * source port) and fall back to the existing route's cross values — the
1424
- * channels ELK already proved navigable. All three segments are proved,
1425
- * the label must fit on the longest lane, and crossings may not increase.
1426
- */
1427
- function tryZRoute(
1428
- edge: PositionedEdge,
1429
- source: PositionedNode,
1430
- target: PositionedNode,
1431
- ctx: LaneContext,
1432
- axis: Axis,
1433
- sourcePortOnly = false,
1434
- forcedLanes?: { sourceLane?: number; targetLane?: number; preferSourceJog?: boolean },
1435
- ): boolean {
1436
- if (edge.points.length < 2) return false
1437
- const srcSpan = attachSpan(source, axis)
1438
- const tgtSpan = attachSpan(target, axis)
1439
- if (!srcSpan || !tgtSpan) return false
1440
- const crossVals = edge.points.map(pt => pt[axis.cross])
1441
-
1442
- const c1s: number[] = []
1443
- const c2s: number[] = []
1444
- const pushTo = (arr: number[], v: number) => {
1445
- if (arr.every(prev => Math.abs(prev - v) > 0.5)) arr.push(v)
1446
- }
1447
- if (forcedLanes?.sourceLane !== undefined) {
1448
- pushTo(c1s, forcedLanes.sourceLane)
1449
- } else {
1450
- pushTo(c1s, source[axis.cross] + (axis.cross === 'y' ? source.height : source.width) / 2)
1451
- if (!sourcePortOnly) {
1452
- pushTo(c1s, edge.points[0]![axis.cross])
1453
- for (const v of crossVals) pushTo(c1s, v)
1454
- }
1455
- }
1456
- if (forcedLanes?.targetLane !== undefined) {
1457
- pushTo(c2s, forcedLanes.targetLane)
1458
- } else {
1459
- pushTo(c2s, target[axis.cross] + (axis.cross === 'y' ? target.height : target.width) / 2)
1460
- pushTo(c2s, edge.points[edge.points.length - 1]![axis.cross])
1461
- }
1462
-
1463
- // Obstacle-clearing escape lanes. When a later node-mover parks an
1464
- // intervening node directly in the entry/exit lane — e.g. a BT hub centred
1465
- // over its fan-in lands its N-side riser under a sibling diamond, the
1466
- // edgeThroughNode signature — the node-centre and existing-route lanes above
1467
- // are ALL blocked, so the Z falls through and the occluding route is kept.
1468
- // For every node sitting in the source→target main-axis corridor, offer the
1469
- // two lanes just past its cross-edges (± CLEARANCE): still on the endpoint's
1470
- // own attach span (the emit stays port-exact), but stepped clear of the
1471
- // obstacle. Appended AFTER the preferred lanes, so a clean case still takes
1472
- // its centre lane first (zero churn); every escape lane is still fully
1473
- // lane-proved below, so this only WIDENS the candidate set — it can never
1474
- // admit an unproved route. Direction-agnostic (keyed off axis.main/cross).
1475
- const mainExtent = (node: PositionedNode): [number, number] => {
1476
- const lo = node[axis.main]
1477
- return [lo, lo + (axis.main === 'x' ? node.width : node.height)]
1478
- }
1479
- const [srcMainLo, srcMainHi] = mainExtent(source)
1480
- const [tgtMainLo, tgtMainHi] = mainExtent(target)
1481
- const corridorLo = Math.min(srcMainLo, srcMainHi, tgtMainLo, tgtMainHi)
1482
- const corridorHi = Math.max(srcMainLo, srcMainHi, tgtMainLo, tgtMainHi)
1483
- const addEscapeLanes = (arr: number[], span: CrossSpan | null): void => {
1484
- if (!span) return
1485
- for (const node of ctx.nodes) {
1486
- if (node.id === edge.source || node.id === edge.target) continue
1487
- const [nMainLo, nMainHi] = mainExtent(node)
1488
- if (nMainHi <= corridorLo + EPS || nMainLo >= corridorHi - EPS) continue // outside the corridor
1489
- const nCrossLo = node[axis.cross]
1490
- const nCrossHi = nCrossLo + (axis.cross === 'y' ? node.height : node.width)
1491
- for (const lane of [nCrossLo - CLEARANCE - EPS, nCrossHi + CLEARANCE + EPS]) {
1492
- if (lane >= span.lo - EPS && lane <= span.hi + EPS) pushTo(arr, lane)
1493
- }
1494
- }
1495
- }
1496
- addEscapeLanes(c1s, forcedLanes?.sourceLane === undefined ? srcSpan : null)
1497
- addEscapeLanes(c2s, forcedLanes?.targetLane === undefined ? tgtSpan : null)
1498
-
1499
- const relaxDiamond = (node: PositionedNode, span: CrossSpan): CrossSpan =>
1500
- node.shape === 'diamond'
1501
- ? { lo: node[axis.cross] + 2, hi: node[axis.cross] + (axis.cross === 'y' ? node.height : node.width) - 2 }
1502
- : span
1503
- const s1 = relaxDiamond(source, srcSpan)
1504
- const s2 = relaxDiamond(target, tgtSpan)
1505
-
1506
- // The route being replaced occludes a non-incident node — a HARD defect
1507
- // (e.g. a later node-mover parked a node in the routed corridor). Only in
1508
- // that repair does the label fall back to an on-route seat when no clear
1509
- // pill slot exists (see below); an ordinary blockage keeps the strict slot.
1510
- const repairingOcclusion = routeThroughNodeBBox(edge, ctx)
1511
- // Deferred on-route label fallback: the first proven occlusion-clearing
1512
- // route whose label lane has capacity but no fully-clear pill slot. Used
1513
- // only if the whole search finds no route with a clear slot, so a clean
1514
- // labelled reroute is always preferred over a grazing one. Deterministic:
1515
- // it is the first such route in the fixed c2×c1×jog candidate order.
1516
- let occlusionFallback: { route: Point[]; labelMid: Point } | null = null
1517
-
1518
- for (const c2 of c2s) {
1519
- if (c2 < s2.lo - EPS || c2 > s2.hi + EPS) continue
1520
- const tgtMain = anchorMain(target, c2, axis, -1)
1521
- for (const c1 of c1s) {
1522
- if (c1 < s1.lo - EPS || c1 > s1.hi + EPS) continue
1523
- if (Math.abs(c1 - c2) < CLEARANCE) continue // that would be a hitch, not a Z
1524
- const srcMain = anchorMain(source, c1, axis, 1)
1525
- if ((tgtMain - srcMain) * axis.sign <= EPS) continue
1526
- const jogs = forcedLanes?.preferSourceJog
1527
- ? [srcMain + axis.sign * 12, (srcMain + tgtMain) / 2, tgtMain - axis.sign * 12]
1528
- : [tgtMain - axis.sign * 12, (srcMain + tgtMain) / 2, srcMain + axis.sign * 12]
1529
- for (const jog of jogs) {
1530
- if ((jog - srcMain) * axis.sign <= EPS || (tgtMain - jog) * axis.sign <= EPS) continue
1531
- // The label rides the longest lane only; the other lane and the
1532
- // hop are proved with a label-stripped probe so the own-label
1533
- // capacity check applies exactly once, where the pill will live.
1534
- const longFirst = Math.abs(jog - srcMain) >= Math.abs(tgtMain - jog)
1535
- const unlabeled: PositionedEdge = { ...edge, points: [], label: undefined }
1536
- const lane1 = directLaneBlockers(longFirst ? edge : unlabeled, c1, Math.min(srcMain, jog), Math.max(srcMain, jog), ctx, axis)
1537
- if (lane1.length > 0) continue
1538
- const hopAxis: Axis = axis.main === 'x'
1539
- ? { main: 'y', cross: 'x', sign: c1 < c2 ? 1 : -1 }
1540
- : { main: 'x', cross: 'y', sign: c1 < c2 ? 1 : -1 }
1541
- if (directLaneBlockers(unlabeled, jog, Math.min(c1, c2), Math.max(c1, c2), ctx, hopAxis).length > 0) continue
1542
- const lane2 = directLaneBlockers(longFirst ? unlabeled : edge, c2, Math.min(jog, tgtMain), Math.max(jog, tgtMain), ctx, axis)
1543
- if (lane2.length > 0) continue
1544
- const pt = (main: number, cross: number): Point =>
1545
- axis.main === 'x' ? { x: main, y: cross } : { x: cross, y: main }
1546
- const route = [pt(srcMain, c1), pt(jog, c1), pt(jog, c2), pt(tgtMain, c2)]
1547
- // Crossings may not increase — unless the current route passes
1548
- // THROUGH a node (a hard defect, e.g. a later pass moved a node
1549
- // into the routed corridor): a legal crossing always beats an
1550
- // occlusion. The Z itself is lane-proved through clear space.
1551
- if (!repairingOcclusion &&
1552
- countRouteCrossings(route, edge, ctx) > countRouteCrossings(edge.points, edge, ctx)) continue
1553
- if (edge.label) {
1554
- const labelLane: [Point, Point] = longFirst ? [route[0]!, route[1]!] : [route[2]!, route[3]!]
1555
- const slot = findLabelSlot(edge, labelLane[0], labelLane[1], ctx)
1556
- if (!slot) {
1557
- // No fully-clear pill slot on this route's label lane. When we are
1558
- // clearing a through-node OCCLUSION (a HARD defect) and the lane
1559
- // proved long enough to CARRY the label (the labeled
1560
- // directLaneBlockers check above passed), remember it as a
1561
- // fallback and keep searching for a route WITH a clear slot: a
1562
- // pill grazing a neighbour's bbox corner is a soft label-placement
1563
- // concern, and trading it for a cleared HARD occlusion is strictly
1564
- // correct by the rubric (labelOffRoute stays satisfied — the pill
1565
- // sits on a real segment). Ordinary blockages never set this, so
1566
- // clean cases keep the strict slot requirement.
1567
- if (repairingOcclusion && !occlusionFallback) {
1568
- occlusionFallback = { route, labelMid: { x: (labelLane[0].x + labelLane[1].x) / 2, y: (labelLane[0].y + labelLane[1].y) / 2 } }
1569
- }
1570
- continue
1571
- }
1572
- edge.labelPosition = slot
1573
- }
1574
- edge.points = route
1575
- return true
1576
- }
1577
- }
1578
- }
1579
- if (occlusionFallback) {
1580
- edge.points = occlusionFallback.route
1581
- if (edge.label) edge.labelPosition = occlusionFallback.labelMid
1582
- return true
1583
- }
1584
- return false
1585
- }
1586
-
1587
- /**
1588
- * Last-resort occlusion repair: a 3-bend "sidestep" detour for the case a
1589
- * 2-bend Z cannot reach — the source's whole flow-side attach span is
1590
- * shadowed by an obstacle, so NO straight riser off that side clears it (a BT
1591
- * hub centred over its fan-in that lands directly beneath a wide downstream
1592
- * diamond — the edgeThroughNode signature this pass exists to kill). The
1593
- * detour leaves the source's flow port, rises a short way into the clear gap
1594
- * just past the source (BEFORE the obstacle), steps sideways to an escape
1595
- * cross-lane that proves clear through the whole corridor, rises along it past
1596
- * the obstacle, then Z-hops into the target's flow port:
1597
- *
1598
- * source ─┐(gap) in BT (main=y, cross=x, "up" = −y):
1599
- * └──────┐(escape lane) a short N riser, a sidestep in the gap
1600
- * │ below the obstacle, a full riser in a
1601
- * └───┐(hop) clear cross-lane, then the hop into H2.
1602
- * ▼target
1603
- *
1604
- * Every one of the five segments is lane-proved (directLaneBlockers), the
1605
- * endpoints stay exact flow ports, and the label rides the longest proven
1606
- * lane (falling back to an on-route seat, exactly as tryZRoute does, since we
1607
- * only run when the alternative is a HARD through-node occlusion). Runs ONLY
1608
- * after tryZRoute fails and the current route occludes a node, so no clean or
1609
- * Z-repairable case is touched. Deterministic: fixed candidate order over the
1610
- * frozen geometry. Returns true iff it rerouted.
1611
- */
1612
- function tryEscapeDetour(
1613
- edge: PositionedEdge,
1614
- source: PositionedNode,
1615
- target: PositionedNode,
1616
- ctx: LaneContext,
1617
- axis: Axis,
1618
- ): boolean {
1619
- if (!routeThroughNodeBBox(edge, ctx)) return false
1620
- const srcSpan = attachSpan(source, axis)
1621
- const tgtSpan = attachSpan(target, axis)
1622
- if (!srcSpan || !tgtSpan) return false
1623
- const unlabeled: PositionedEdge = { ...edge, points: [], label: undefined }
1624
- const crossSize = (n: PositionedNode) => axis.cross === 'y' ? n.height : n.width
1625
- const mainSizeOf = (n: PositionedNode) => axis.main === 'x' ? n.width : n.height
1626
-
1627
- // Source flow-port lane (cross-centre) and its forward main edge.
1628
- const spCross = source[axis.cross] + crossSize(source) / 2
1629
- const srcMain = source[axis.main] + (axis.sign > 0 ? mainSizeOf(source) : 0)
1630
- // Target flow-port lane and its backward main edge.
1631
- const tpCross = target[axis.cross] + crossSize(target) / 2
1632
- const tgtMain = target[axis.main] + (axis.sign > 0 ? 0 : mainSizeOf(target))
1633
- if (Math.abs(spCross - srcSpan.lo) > EPS && Math.abs(spCross - srcSpan.hi) > EPS &&
1634
- (spCross < srcSpan.lo - EPS || spCross > srcSpan.hi + EPS)) return false
1635
-
1636
- // Corridor obstacles: non-incident nodes whose main-extent lies between the
1637
- // source's forward edge and the target's backward edge. The nearest one
1638
- // (measured forward from the source) bounds the gap the sidestep runs in.
1639
- interface Obs { mainNear: number; crossLo: number; crossHi: number }
1640
- const obstacles: Obs[] = []
1641
- for (const n of ctx.nodes) {
1642
- if (n.id === edge.source || n.id === edge.target) continue
1643
- const nMainLo = n[axis.main], nMainHi = nMainLo + mainSizeOf(n)
1644
- const beyondSrc = (nMainLo - srcMain) * axis.sign > EPS || (nMainHi - srcMain) * axis.sign > EPS
1645
- const beforeTgt = (tgtMain - nMainLo) * axis.sign > EPS || (tgtMain - nMainHi) * axis.sign > EPS
1646
- if (!beyondSrc || !beforeTgt) continue
1647
- const mainNear = axis.sign > 0 ? nMainLo : nMainHi
1648
- obstacles.push({ mainNear, crossLo: n[axis.cross], crossHi: n[axis.cross] + crossSize(n) })
1649
- }
1650
- // Only worth attempting when an obstacle actually shadows the source lane.
1651
- const shadowing = obstacles.filter(o => spCross > o.crossLo - CLEARANCE && spCross < o.crossHi + CLEARANCE)
1652
- if (shadowing.length === 0) return false
1653
- // Gap main-coord between the source's exit and the nearest shadowing
1654
- // obstacle's near edge — the lane the sidestep travels along.
1655
- const nearestNear = shadowing.reduce((best, o) => (o.mainNear - srcMain) * axis.sign < (best - srcMain) * axis.sign ? o.mainNear : best, shadowing[0]!.mainNear)
1656
- if ((nearestNear - srcMain) * axis.sign <= CLEARANCE) return false // no room to sidestep before the obstacle
1657
- const gapMain = srcMain + (nearestNear - srcMain) / 2
1658
- // Hop main-coord just short of the target's backward edge (mirrors tryZRoute's jog).
1659
- const hopMain = tgtMain - axis.sign * 12
1660
- if ((hopMain - gapMain) * axis.sign <= EPS) return false
1661
-
1662
- // Escape cross-lanes: just past every obstacle's cross-edges, and the
1663
- // midpoints of the gaps BETWEEN adjacent obstacles (a clean channel like the
1664
- // R0|R1 gap). Nearest-to-the-source-lane first, so the detour stays compact.
1665
- const escCandidates: number[] = []
1666
- const pushCand = (v: number) => { if (escCandidates.every(p => Math.abs(p - v) > 0.5)) escCandidates.push(v) }
1667
- for (const o of obstacles) { pushCand(o.crossLo - CLEARANCE - EPS); pushCand(o.crossHi + CLEARANCE + EPS) }
1668
- const crossSorted = [...obstacles].sort((a, b) => a.crossLo - b.crossLo)
1669
- for (let i = 1; i < crossSorted.length; i++) {
1670
- const gapLo = crossSorted[i - 1]!.crossHi, gapHi = crossSorted[i]!.crossLo
1671
- if (gapHi - gapLo > 2 * CLEARANCE) pushCand((gapLo + gapHi) / 2)
1672
- }
1673
- escCandidates.sort((a, b) => Math.abs(a - spCross) - Math.abs(b - spCross))
1674
-
1675
- const pt = (main: number, cross: number): Point =>
1676
- axis.main === 'x' ? { x: main, y: cross } : { x: cross, y: main }
1677
- const perpAxis = (from: number, to: number): Axis => axis.main === 'x'
1678
- ? { main: 'y', cross: 'x', sign: from < to ? 1 : -1 }
1679
- : { main: 'x', cross: 'y', sign: from < to ? 1 : -1 }
1680
-
1681
- let fallback: { route: Point[]; labelMid: Point } | null = null
1682
- for (const escCross of escCandidates) {
1683
- if (Math.abs(escCross - spCross) < CLEARANCE) continue // that is just the shadowed lane
1684
- // A: short riser off the source flow port, in the pre-obstacle gap.
1685
- if (directLaneBlockers(unlabeled, spCross, Math.min(srcMain, gapMain), Math.max(srcMain, gapMain), ctx, axis).length > 0) continue
1686
- // B: sidestep along the gap to the escape lane.
1687
- if (directLaneBlockers(unlabeled, gapMain, Math.min(spCross, escCross), Math.max(spCross, escCross), ctx, perpAxis(spCross, escCross)).length > 0) continue
1688
- // C: full riser along the escape lane, up to the hop.
1689
- if (directLaneBlockers(unlabeled, escCross, Math.min(gapMain, hopMain), Math.max(gapMain, hopMain), ctx, axis).length > 0) continue
1690
- // D: hop across to the target's flow-port lane.
1691
- if (directLaneBlockers(unlabeled, hopMain, Math.min(escCross, tpCross), Math.max(escCross, tpCross), ctx, perpAxis(escCross, tpCross)).length > 0) continue
1692
- // E: drop into the target flow port.
1693
- if (directLaneBlockers(unlabeled, tpCross, Math.min(hopMain, tgtMain), Math.max(hopMain, tgtMain), ctx, axis).length > 0) continue
1694
- const route = simplifyPolyline([
1695
- pt(srcMain, spCross), pt(gapMain, spCross), pt(gapMain, escCross),
1696
- pt(hopMain, escCross), pt(hopMain, tpCross), pt(tgtMain, tpCross),
1697
- ])
1698
- // The escape riser (segment C) is the longest lane and the only one that
1699
- // reliably clears the obstacle band — seat the label there.
1700
- const labelLane: [Point, Point] = [pt(gapMain, escCross), pt(hopMain, escCross)]
1701
- if (edge.label) {
1702
- const slot = findLabelSlot(edge, labelLane[0], labelLane[1], ctx)
1703
- if (slot) { edge.labelPosition = slot; edge.points = route; return true }
1704
- if (!fallback) fallback = { route, labelMid: { x: (labelLane[0].x + labelLane[1].x) / 2, y: (labelLane[0].y + labelLane[1].y) / 2 } }
1705
- continue
1706
- }
1707
- edge.points = route
1708
- return true
1709
- }
1710
- if (fallback) {
1711
- edge.points = fallback.route
1712
- if (edge.label) edge.labelPosition = fallback.labelMid
1713
- return true
1714
- }
1715
- return false
1716
- }
1717
-
1718
- /**
1719
- * Port-ranking hook (the flowchart convention IBM's manuals illustrate and
1720
- * yFiles' FlowchartLayout encodes): when a vertex emit cannot go straight,
1721
- * the next-best route is a single-bend L — the vertex lane along the flow
1722
- * axis, then ONE perpendicular drop into the target's FACING cross-side
1723
- * port (the box's top/bottom in horizontal flow). One bend beats the
1724
- * 2-bend Z, both endpoints are exact canonical ports, and the target's
1725
- * flow-side port stays free for its fan-in siblings. Skipped when the
1726
- * entry stub would be shorter than HOOK_STUB_MIN (degenerate when the
1727
- * vertex lane grazes the target, as in TD where siblings sit beside each
1728
- * other) — the Z remains the fallback there.
1729
- */
1730
- function tryVertexHook(
1731
- edge: PositionedEdge,
1732
- source: PositionedNode,
1733
- target: PositionedNode,
1734
- ctx: LaneContext,
1735
- axis: Axis,
1736
- ): boolean {
1737
- if (edge.points.length < 2) return false
1738
- const lane = source[axis.cross] + (axis.cross === 'y' ? source.height : source.width) / 2
1739
- const tgtCrossLo = target[axis.cross]
1740
- const tgtCrossHi = tgtCrossLo + (axis.cross === 'y' ? target.height : target.width)
1741
- // The lane must pass clear of the target's cross extent; when it
1742
- // overlaps, the straight emit or the Z is the right tool, not a hook.
1743
- // The entry point is the exact facing cross-side port from shapePorts
1744
- // (bbox side midpoint for symmetric shapes, on-outline for the slanted
1745
- // family), not a bare bbox coordinate.
1746
- let entrySide: PortSide
1747
- if (lane < tgtCrossLo - EPS) entrySide = axis.cross === 'y' ? 'N' : 'W'
1748
- else if (lane > tgtCrossHi + EPS) entrySide = axis.cross === 'y' ? 'S' : 'E'
1749
- else return false
1750
- const entryCross = shapePorts(target)[entrySide][axis.cross]
1751
- if (Math.abs(entryCross - lane) < HOOK_STUB_MIN) return false
1752
- // Fan-in merge outranks the hook: when a same-target sibling already
1753
- // holds the flow-side entry port, the Z that converges there into one
1754
- // shared arrowhead (yFiles edge grouping; Kakoulis–Tollis label/edge
1755
- // unambiguity) beats splitting the fan-in across two sides. The fixed
1756
- // point makes this order-independent: a hook taken before the sibling
1757
- // settles is re-proved and downgraded on the next round.
1758
- const entryPort = shapePorts(target)[facingSide(axis, -1)]
1759
- const mergeAvailable = ctx.edges.some(other => other !== edge &&
1760
- other.target === edge.target && other.source !== edge.source &&
1761
- other.points.length > 0 &&
1762
- Math.abs(other.points[other.points.length - 1]!.x - entryPort.x) <= PORT_TOLERANCE &&
1763
- Math.abs(other.points[other.points.length - 1]!.y - entryPort.y) <= PORT_TOLERANCE)
1764
- if (mergeAvailable) return false
1765
- const portMain = target[axis.main] + (axis.main === 'x' ? target.width : target.height) / 2
1766
- const srcMain = anchorMain(source, lane, axis, 1)
1767
- if ((portMain - srcMain) * axis.sign <= EPS) return false
1768
- // The label rides the lane; the entry stub is proved label-stripped
1769
- // (own-label capacity applies exactly once, where the pill lives).
1770
- const unlabeled: PositionedEdge = { ...edge, points: [], label: undefined }
1771
- if (directLaneBlockers(edge, lane, Math.min(srcMain, portMain), Math.max(srcMain, portMain), ctx, axis).length > 0) return false
1772
- const stubAxis: Axis = axis.main === 'x'
1773
- ? { main: 'y', cross: 'x', sign: lane < entryCross ? 1 : -1 }
1774
- : { main: 'x', cross: 'y', sign: lane < entryCross ? 1 : -1 }
1775
- if (directLaneBlockers(unlabeled, portMain, Math.min(lane, entryCross), Math.max(lane, entryCross), ctx, stubAxis).length > 0) return false
1776
- const pt = (main: number, cross: number): Point =>
1777
- axis.main === 'x' ? { x: main, y: cross } : { x: cross, y: main }
1778
- const route = [pt(srcMain, lane), pt(portMain, lane), pt(portMain, entryCross)]
1779
- if (!routeThroughNodeBBox(edge, ctx) &&
1780
- countRouteCrossings(route, edge, ctx) > countRouteCrossings(edge.points, edge, ctx)) return false
1781
- if (edge.label) {
1782
- const slot = findLabelSlot(edge, route[0]!, route[1]!, ctx)
1783
- if (!slot) return false
1784
- edge.labelPosition = slot
1785
- }
1786
- edge.points = route
1787
- return true
1788
- }
1789
-
1790
- /**
1791
- * ELK's feedbackEdges routing can wrap a loop around the source's forward
1792
- * side before reaching its outer channel — an excursion that parks the
1793
- * loop's drop column in the corridor a forward sibling must cross. When
1794
- * the source's facet can reach the channel directly (a single proven
1795
- * perpendicular hop, or an on-boundary anchor when the source spans the
1796
- * channel), cut the excursion: the loop exits toward its channel
1797
- * immediately. Only existing channel geometry is reused, so every kept
1798
- * segment was already proven by ELK; the one NEW segment is lane-proved.
1799
- */
1800
- function tightenOuterFeedback(edge: PositionedEdge, ctx: LaneContext): boolean {
1801
- const points = simplifyPolyline(edge.points)
1802
- if (points.length < 4) return false
1803
- const graphAxis = ctx.axis
1804
- const crossOf = (pt: Point) => pt[graphAxis.cross]
1805
- const mainOf = (pt: Point) => pt[graphAxis.main]
1806
- // The outer channel is the run at the route's extreme cross value.
1807
- const crossValues = points.map(crossOf)
1808
- const extremeHi = Math.max(...crossValues)
1809
- const extremeLo = Math.min(...crossValues)
1810
- const startCross = crossOf(points[0]!)
1811
- const extreme = Math.abs(extremeHi - startCross) >= Math.abs(extremeLo - startCross) ? extremeHi : extremeLo
1812
- let runStart = -1
1813
- for (let i = 1; i < points.length; i++) {
1814
- if (Math.abs(crossOf(points[i - 1]!) - extreme) < EPS && Math.abs(crossOf(points[i]!) - extreme) < EPS) {
1815
- runStart = i - 1
1816
- break
1817
- }
1818
- }
1819
- if (runStart < 1) return false // no excursion before the channel run
1820
- const run = [points[runStart]!, points[runStart + 1]!]
1821
- const runDir = Math.sign(mainOf(run[1]!) - mainOf(run[0]!))
1822
- const p0 = points[0]!
1823
- const source = ctx.nodes.find(n => n.id === edge.source)
1824
- const inRunRange = (m: number) => runDir > 0
1825
- ? m >= mainOf(run[0]!) - EPS && m < mainOf(run[1]!) - EPS
1826
- : m <= mainOf(run[0]!) + EPS && m > mainOf(run[1]!) + EPS
1827
-
1828
- const hopClear = (mainCoord: number, fromCross: number): boolean => {
1829
- if (Math.abs(fromCross - extreme) < EPS) return true
1830
- const hopAxis: Axis = graphAxis.main === 'x'
1831
- ? { main: 'y', cross: 'x', sign: fromCross < extreme ? 1 : -1 }
1832
- : { main: 'x', cross: 'y', sign: fromCross < extreme ? 1 : -1 }
1833
- // The label rides the channel run, not the hop — strip it so the
1834
- // own-label capacity check doesn't demand the pill fit on a short hop.
1835
- const probe: PositionedEdge = { ...edge, points: [], label: undefined }
1836
- return directLaneBlockers(probe, mainCoord,
1837
- Math.min(fromCross, extreme), Math.max(fromCross, extreme), ctx, hopAxis).length === 0
1838
- }
1839
- const buildRoute = (mainCoord: number, fromCross: number, startOnBoundary: Point): Point[] => {
1840
- const hopPoint: Point = graphAxis.main === 'x' ? { x: mainCoord, y: extreme } : { x: extreme, y: mainCoord }
1841
- return Math.abs(fromCross - extreme) < EPS
1842
- ? [hopPoint, ...points.slice(runStart + 1)]
1843
- : [startOnBoundary, hopPoint, ...points.slice(runStart + 1)]
1844
- }
1845
-
1846
- let tightened: Point[] | null = null
1847
- // Variant 1 — exit via the source's channel-facing PORT: the decision
1848
- // diamond's exact South/North vertex (a rectangle's side midpoint). One
1849
- // short hop from the port to the channel is proven; the run truncation
1850
- // reuses existing, already-proven channel geometry.
1851
- if (source && PORT_EXACT.has(source.shape)) {
1852
- const srcCrossLo = source[graphAxis.cross]
1853
- // The exact channel-facing port from shapePorts: the bbox side midpoint
1854
- // for the symmetric shapes, the slant midpoint / flag point for the
1855
- // slanted family — never a bare bbox coordinate off the outline.
1856
- const side: PortSide = graphAxis.cross === 'y'
1857
- ? (extreme > srcCrossLo ? 'S' : 'N')
1858
- : (extreme > srcCrossLo ? 'E' : 'W')
1859
- const port = shapePorts(source)[side]
1860
- const portCross = port[graphAxis.cross]
1861
- const portMain = port[graphAxis.main]
1862
- if (inRunRange(portMain) && hopClear(portMain, portCross)) {
1863
- tightened = buildRoute(portMain, portCross, port)
1864
- }
1865
- }
1866
- // Variant 1b — the shape spans the channel itself: exit straight off the
1867
- // channel-facing facet AT the channel height (no hop at all).
1868
- if (!tightened && source && (RECT_LIKE.has(source.shape) || source.shape === 'diamond')) {
1869
- const srcCrossLo = source[graphAxis.cross]
1870
- const srcCrossHi = srcCrossLo + (graphAxis.cross === 'y' ? source.height : source.width)
1871
- if (extreme > srcCrossLo + EPS && extreme < srcCrossHi - EPS) {
1872
- const facingRun: 1 | -1 = runDir === graphAxis.sign ? 1 : -1
1873
- const anchorM = anchorMain(source, extreme, graphAxis, facingRun)
1874
- if (inRunRange(anchorM) && hopClear(anchorM, extreme)) {
1875
- const boundary: Point = graphAxis.main === 'x' ? { x: anchorM, y: extreme } : { x: extreme, y: anchorM }
1876
- tightened = buildRoute(anchorM, extreme, boundary)
1877
- }
1878
- }
1879
- }
1880
- // Variant 2 — hop perpendicular from ELK's exit point to the channel.
1881
- if (!tightened && inRunRange(mainOf(p0)) && hopClear(mainOf(p0), crossOf(p0))) {
1882
- tightened = buildRoute(mainOf(p0), crossOf(p0), p0)
1883
- }
1884
- // Tail tightening — symmetric: enter the target via its channel-facing
1885
- // PORT (the South/North side midpoint), truncating the run at the
1886
- // target's center and hopping up from the channel.
1887
- const base = tightened ?? points
1888
- let tailApplied = false
1889
- {
1890
- let rs = -1
1891
- for (let i = 1; i < base.length; i++) {
1892
- if (Math.abs(crossOf(base[i - 1]!) - extreme) < EPS && Math.abs(crossOf(base[i]!) - extreme) < EPS) {
1893
- rs = i - 1
1894
- break
1895
- }
1896
- }
1897
- const target = ctx.nodes.find(n => n.id === edge.target)
1898
- if (rs >= 0 && rs + 1 < base.length - 1 && target && PORT_EXACT.has(target.shape)) {
1899
- const tCrossLo = target[graphAxis.cross]
1900
- // Exact channel-facing port via shapePorts (see the head variant).
1901
- const side: PortSide = graphAxis.cross === 'y'
1902
- ? (extreme > tCrossLo ? 'S' : 'N')
1903
- : (extreme > tCrossLo ? 'E' : 'W')
1904
- const port = shapePorts(target)[side]
1905
- const portCross = port[graphAxis.cross]
1906
- const portMain = port[graphAxis.main]
1907
- const runA = base[rs]!, runB = base[rs + 1]!
1908
- const dir = Math.sign(mainOf(runB) - mainOf(runA))
1909
- const within = dir > 0
1910
- ? portMain > mainOf(runA) + EPS && portMain <= mainOf(runB) + EPS
1911
- : portMain < mainOf(runA) - EPS && portMain >= mainOf(runB) - EPS
1912
- if (within && hopClear(portMain, portCross)) {
1913
- const runEndAtPort: Point = graphAxis.main === 'x' ? { x: portMain, y: extreme } : { x: extreme, y: portMain }
1914
- tightened = [...base.slice(0, rs + 1), runEndAtPort, port]
1915
- tailApplied = true
1916
- }
1917
- }
1918
- }
1919
- if (!tightened) return false
1920
- // Same rule as straightening: tightening may never increase crossings.
1921
- if (countRouteCrossings(simplifyPolyline(tightened), edge, ctx) > countRouteCrossings(edge.points, edge, ctx)) return false
1922
- const before = edge.points
1923
- edge.points = simplifyPolyline(tightened)
1924
- // Re-place the label if its pill sat on a removed segment.
1925
- if (edge.label && edge.labelPosition) {
1926
- const lp = edge.labelPosition
1927
- const onRoute = edge.points.some((pt, i) => {
1928
- if (i === 0) return false
1929
- const a = edge.points[i - 1]!, b = pt
1930
- const xLo = Math.min(a.x, b.x) - 1, xHi = Math.max(a.x, b.x) + 1
1931
- const yLo = Math.min(a.y, b.y) - 1, yHi = Math.max(a.y, b.y) + 1
1932
- return lp.x >= xLo && lp.x <= xHi && lp.y >= yLo && lp.y <= yHi
1933
- })
1934
- if (!onRoute) {
1935
- // The channel run survives tightening: find it on the new route.
1936
- let slot: Point | null = null
1937
- for (let i = 1; i < edge.points.length; i++) {
1938
- const a = edge.points[i - 1]!, b = edge.points[i]!
1939
- if (Math.abs(crossOf(a) - extreme) < EPS && Math.abs(crossOf(b) - extreme) < EPS) {
1940
- slot = findLabelSlot(edge, a, b, ctx)
1941
- break
1942
- }
1943
- }
1944
- if (slot) {
1945
- edge.labelPosition = slot
1946
- } else {
1947
- edge.points = before // no honest label slot on the tightened loop: keep ELK's
1948
- return false
1949
- }
1950
- }
1951
- }
1952
- return JSON.stringify(edge.points) !== JSON.stringify(before)
1953
- }
1954
-
1955
- /**
1956
- * A feedback route that escapes the cross-axis band of its endpoint nodes
1957
- * is the certified ELK feedbackEdges loop — "feedback uses the feedback
1958
- * side and an outer channel" (issue #25 §11.3). Returns null for
1959
- * non-feedback edges so callers keep their own detour vocabulary.
1960
- */
1961
- function feedbackDetourKind(
1962
- edge: PositionedEdge,
1963
- routeClass: RouteClass,
1964
- source: PositionedNode | undefined,
1965
- target: PositionedNode | undefined,
1966
- graphAxis: Axis,
1967
- ): RouteInvariant | null {
1968
- if (routeClass !== 'feedback') return null
1969
- if (!source || !target) return 'feedback-detour'
1970
- const crossSize = (n: PositionedNode) => graphAxis.cross === 'y' ? n.height : n.width
1971
- const bandLo = Math.min(source[graphAxis.cross], target[graphAxis.cross]) - 2
1972
- const bandHi = Math.max(source[graphAxis.cross] + crossSize(source), target[graphAxis.cross] + crossSize(target)) + 2
1973
- const escapes = edge.points.some(pt => pt[graphAxis.cross] < bandLo || pt[graphAxis.cross] > bandHi)
1974
- return escapes ? 'outer-feedback' : 'feedback-detour'
1975
- }
1976
-
1977
- const retry: Array<{ edge: PositionedEdge; cert: DraftRouteCertificate }> = []
1978
- for (const edge of positioned.edges) {
1979
- edge.points = simplifyPolyline(orthogonalizeResidualDiagonals(edge.points))
1980
-
1981
- const edgeIndex = edge.edgeIndex ?? -1
1982
- const routeClass: RouteClass = classes[edgeIndex] ?? 'primary-forward'
1983
- const cert: DraftRouteCertificate = {
1984
- edgeIndex,
1985
- routeClass,
1986
- bendCount: bendCount(edge.points),
1987
- invariant: 'straight',
1988
- }
1989
-
1990
- if (bundled.has(edge)) {
1991
- cert.invariant = 'bundle'
1992
- } else if (routeClass === 'self-loop') {
1993
- cert.invariant = 'self-loop'
1994
- } else if (routeClass === 'container') {
1995
- cert.invariant = 'container-attach'
1996
- if (tryRepairContainerEdge(edge, groupMap, nodeMap, ctx)) {
1997
- cert.bendCount = 0
1998
- cert.directLaneClear = true
1999
- cert.straightened = true
2000
- }
2001
- } else if (routeClass === 'primary-forward' || routeClass === 'feedback') {
2002
- if (cert.bendCount > 0) {
2003
- const r = attemptStraighten(edge, cert)
2004
- if (!r.applied || r.upgradeable) retry.push({ edge, cert })
2005
- } else {
2006
- // Already straight, but possibly off-port: ELK's FREE placement can
2007
- // leave a straight lane floating beside the side midpoints. Re-lane
2008
- // through the ports when that proves clear — the candidate order
2009
- // tries the target port first and falls back to the current lane,
2010
- // so a blocked port lane leaves the edge untouched.
2011
- const sourceNode = nodeMap.get(edge.source)
2012
- const targetNode = nodeMap.get(edge.target)
2013
- // A reciprocal-pair member must enroll even when straight and
2014
- // on-port: its contract lane is the symmetric one (center ± SEP/2),
2015
- // not the port lane, and the symmetric lane often only clears after
2016
- // its partner is repaired.
2017
- const reciprocal = !edge.label && edge.edgeIndex !== undefined &&
2018
- positioned.edges.some(other => other !== edge &&
2019
- other.source === edge.target && other.target === edge.source &&
2020
- !other.label && other.edgeIndex !== undefined)
2021
- if (sourceNode && targetNode &&
2022
- (!portAt(sourceNode, edge.points[0]!) || !portAt(targetNode, edge.points[edge.points.length - 1]!) ||
2023
- routeThroughNodeBBox(edge, ctx) || reciprocal)) {
2024
- // Enroll in the fixed-point pool like every other repair: an
2025
- // upgrade blocked now (e.g. a label pill in the vertex lane) may
2026
- // clear once a later repair moves the obstacle.
2027
- const r = attemptStraighten(edge, cert)
2028
- if (!r.applied || r.upgradeable) retry.push({ edge, cert })
2029
- }
2030
- }
2031
- }
2032
-
2033
- drafts.push({ edge, cert })
2034
- }
2035
-
2036
- // Fixed point: each round re-proves edges that failed OR landed on a
2037
- // non-preferred lane (upgradeable), and only keeps iterating while some
2038
- // geometry actually moved — re-proving an unchanged lane is idempotent, so
2039
- // the loop terminates as soon as a round is a no-op; 4 rounds cover practice.
2040
- for (let round = 0; round < 4 && retry.length > 0; round++) {
2041
- const still: typeof retry = []
2042
- let changed = false
2043
- for (const item of retry) {
2044
- if (item.cert.invariant === 'unverified-shape') continue
2045
- const r = attemptStraighten(item.edge, item.cert)
2046
- if (r.mutated) changed = true
2047
- if (!r.applied || r.upgradeable) still.push(item)
2048
- }
2049
- if (!changed) break
2050
- retry.length = 0
2051
- retry.push(...still)
2052
- }
2053
-
2054
- for (const edge of positioned.edges) {
2055
- const sourceNode = nodeMap.get(edge.source)
2056
- const targetNode = nodeMap.get(edge.target)
2057
- if (sourceNode) repairRectLikeEndpointOverflow(edge, sourceNode, true)
2058
- if (targetNode) repairRectLikeEndpointOverflow(edge, targetNode, false)
2059
- }
2060
-
2061
- const certificates: RouteCertificate[] = []
2062
- const finalPortPlan = allocateRoutePorts(positioned, graph, classes)
2063
- const finalizeCertificate = (edge: PositionedEdge, draft: DraftRouteCertificate): RouteCertificate => {
2064
- const bendCountFinal = bendCount(edge.points)
2065
- const sourceNode = nodeMap.get(edge.source)
2066
- const targetNode = nodeMap.get(edge.target)
2067
- const portAssignment = finalPortPlan.byEdge.get(edge)
2068
- const base = {
2069
- edgeIndex: draft.edgeIndex,
2070
- routeClass: draft.routeClass,
2071
- bendCount: bendCountFinal,
2072
- directLaneClear: draft.directLaneClear,
2073
- directLaneBlockedBy: draft.directLaneBlockedBy,
2074
- sourcePort: sourceNode && edge.points.length > 0 ? portAt(sourceNode, edge.points[0]!) : undefined,
2075
- targetPort: targetNode && edge.points.length > 0 ? portAt(targetNode, edge.points[edge.points.length - 1]!) : undefined,
2076
- sourcePortAssignment: portAssignment?.source,
2077
- targetPortAssignment: portAssignment?.target,
2078
- }
2079
- // Public route certificates are a discriminated union: only final
2080
- // straight routes may carry `straightened`. A retry can downgrade an
2081
- // earlier straightening to a detour; the type forbids exporting that
2082
- // stale impossible state.
2083
- if (draft.invariant === 'straight' && bendCountFinal === 0 && edge.points.length === 2) {
2084
- return { ...base, invariant: 'straight', ...(draft.straightened ? { straightened: true as const } : {}) }
2085
- }
2086
- return { ...base, invariant: draft.invariant === 'straight' ? 'explained-detour' : draft.invariant }
2087
- }
2088
-
2089
- // Record final route facts after ALL geometry has settled (straightening,
2090
- // tightening, and fixed-point retries).
2091
- for (const { edge, cert: draft } of drafts) {
2092
- const cert = finalizeCertificate(edge, draft)
2093
- edge.routeCertificate = cert
2094
- certificates.push(cert)
2095
- }
2096
-
2097
- return certificates
2098
- }
2099
-
2100
- /**
2101
- * Re-issue a certificate for an edge a post-freeze repair has re-routed. A pass
2102
- * that mutates final route geometry must hand the completeness contract on —
2103
- * an uncertified edge is exactly what the certificate system exists to forbid
2104
- * (issue #83: the shared-trunk label lane shipped one). The route class and
2105
- * direct-lane analysis are inherited from the pre-repair certificate (they
2106
- * describe the source→target relationship, not the polyline); the bend count
2107
- * and port contacts are re-measured from the repaired geometry; and a repaired
2108
- * route only claims 'straight' when it measurably is one now — never a
2109
- * straightening, which belongs to the certifying straightener alone.
2110
- */
2111
- export function recertifyReroutedEdge(
2112
- edge: PositionedEdge,
2113
- saved: RouteCertificate | undefined,
2114
- sourceNode?: PositionedNode,
2115
- targetNode?: PositionedNode,
2116
- ): void {
2117
- if (!saved) { edge.routeCertificate = undefined; return }
2118
- const base = {
2119
- edgeIndex: saved.edgeIndex,
2120
- routeClass: saved.routeClass,
2121
- bendCount: bendCount(edge.points),
2122
- directLaneClear: saved.directLaneClear,
2123
- directLaneBlockedBy: saved.directLaneBlockedBy,
2124
- sourcePort: sourceNode && edge.points.length > 0 ? portAt(sourceNode, edge.points[0]!) : saved.sourcePort,
2125
- targetPort: targetNode && edge.points.length > 0 ? portAt(targetNode, edge.points[edge.points.length - 1]!) : saved.targetPort,
2126
- sourcePortAssignment: saved.sourcePortAssignment,
2127
- targetPortAssignment: saved.targetPortAssignment,
2128
- }
2129
- if (edge.points.length === 2 && bendCount(edge.points) === 0) {
2130
- edge.routeCertificate = { ...base, invariant: 'straight' }
2131
- return
2132
- }
2133
- edge.routeCertificate = {
2134
- ...base,
2135
- invariant: saved.invariant === 'straight' ? 'explained-detour' : saved.invariant,
2136
- }
2137
- }
2138
-
2139
- // ============================================================================
2140
- // Validation (consumed by verifyMermaid as ROUTE_HITCH)
2141
- // ============================================================================
2142
-
2143
- export interface RouteHitch {
2144
- edge: string
2145
- deviationPx: number
2146
- }
2147
-
2148
- /**
2149
- * Re-prove the straight-lane invariant over FINAL geometry. A hitch is a
2150
- * primary-forward or feedback edge that still bends although a clear
2151
- * candidate lane exists for it. The layout pass straightens these itself, so
2152
- * any hit here means a later pass mutated geometry after certification — the
2153
- * tripwire issue #25 acceptance criterion 3 asks for.
2154
- */
2155
- export function findRouteHitches(
2156
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[]; groups: PositionedGroup[] },
2157
- graph: MermaidGraph,
2158
- style: LabelMetricsStyle = resolveRenderStyle({}),
2159
- ): RouteHitch[] {
2160
- const axis = axisFor(graph.direction)
2161
- const nodeMap = new Map(positioned.nodes.map(n => [n.id, n]))
2162
- // Mirror the layout pass's priority rules using the certified classes.
2163
- const classes: RouteClass[] = []
2164
- for (const e of positioned.edges) {
2165
- if (e.edgeIndex !== undefined && e.routeCertificate) classes[e.edgeIndex] = e.routeCertificate.routeClass
2166
- }
2167
- // And mirror the port-ranking occupancy so the prover applies the same
2168
- // vertex-emit policy (a deliberate vertex Z is not a hitch).
2169
- const sideUse = new Map<string, number>()
2170
- const bumpSide = (nodeId: string, side: PortSide) => {
2171
- const key = `${nodeId}:${side}`
2172
- sideUse.set(key, (sideUse.get(key) ?? 0) + 1)
2173
- }
2174
- for (const e of positioned.edges) {
2175
- const cls = e.routeCertificate?.routeClass
2176
- if (cls === 'primary-forward') {
2177
- bumpSide(e.source, facingSide(axis, 1))
2178
- bumpSide(e.target, facingSide(axis, -1))
2179
- } else if (cls === 'feedback' && !e.label) {
2180
- const flipped: Axis = { ...axis, sign: axis.sign === 1 ? -1 : 1 }
2181
- bumpSide(e.source, facingSide(flipped, 1))
2182
- bumpSide(e.target, facingSide(flipped, -1))
2183
- }
2184
- }
2185
- const ctx: LaneContext = { nodes: positioned.nodes, edges: positioned.edges, axis, style, classes, sideUse }
2186
- const hitches: RouteHitch[] = []
2187
-
2188
- for (const edge of positioned.edges) {
2189
- const cert = edge.routeCertificate
2190
- if (!cert || cert.invariant === 'bundle') continue
2191
- if (cert.routeClass !== 'primary-forward' && cert.routeClass !== 'feedback') continue
2192
- const edgeAxis: Axis = cert.routeClass === 'feedback' ? { ...axis, sign: axis.sign === 1 ? -1 : 1 } : axis
2193
- const points = simplifyPolyline(edge.points)
2194
- if (points.length <= 2) continue
2195
- const source = nodeMap.get(edge.source)
2196
- const target = nodeMap.get(edge.target)
2197
- if (!source || !target) continue
2198
- if (!isStraightenable(source.shape)) continue
2199
- if (!isStraightenable(target.shape)) continue
2200
- if (cert.routeClass !== 'feedback' && !isMonotoneStaircase(points, edgeAxis)) continue
2201
- const portConstraint = diamondSpreadTargetPortConstraint(edge, source, target, ctx, edgeAxis)
2202
- if (portConstraint && satisfiesDiamondSpreadTargetPortConstraint(edge, source, target, portConstraint)) continue
2203
- // Vertex-emit edges (single-line diamond side) bend deliberately when no
2204
- // straight vertex lane exists; their probe is restricted to that lane.
2205
- const srcSideUse = sideUse.get(`${edge.source}:${facingSide(edgeAxis, 1)}`) ?? 1
2206
- const emitFromVertex = cert.routeClass === 'primary-forward' &&
2207
- source.shape === 'diamond' && srcSideUse === 1 &&
2208
- portSharpness(source.shape) >= portSharpness(target.shape)
2209
- const vertexLane = emitFromVertex
2210
- ? source[edgeAxis.cross] + (edgeAxis.cross === 'y' ? source.height : source.width) / 2
2211
- : undefined
2212
- // Probe on a copy so validation never mutates the layout.
2213
- const probe: PositionedEdge = { ...edge, points: points.map(p => ({ ...p })) }
2214
- const attempt = tryStraighten(probe, source, target, ctx, edgeAxis, vertexLane)
2215
- if (attempt.applied) {
2216
- hitches.push({ edge: edgeId(edge), deviationPx: Math.round(crossDeviation(points, edgeAxis) * 10) / 10 })
2217
- }
2218
- }
2219
- return hitches
2220
- }
2221
-
2222
- /**
2223
- * Container edges must run border to border (spec §11.5). Under SEPARATE
2224
- * hierarchy mode (subgraph direction overrides) ELK can leave a
2225
- * container-to-container edge floating in the diagram margin, attached to
2226
- * neither box. When the two end rects are cleanly separated along one axis,
2227
- * collapse the route onto a proven straight lane between the facing borders
2228
- * — the same machinery as node straightening, with the container rect
2229
- * standing in as a rectangle. Returns true when the route was repaired.
2230
- */
2231
- function tryRepairContainerEdge(
2232
- edge: PositionedEdge,
2233
- groupMap: Map<string, PositionedGroup>,
2234
- nodeMap: Map<string, PositionedNode>,
2235
- ctx: LaneContext,
2236
- ): boolean {
2237
- const rectFor = (id: string): PositionedNode | null => {
2238
- const group = groupMap.get(id)
2239
- if (group) return { id, label: '', shape: 'rectangle', x: group.x, y: group.y, width: group.width, height: group.height }
2240
- const node = nodeMap.get(id)
2241
- if (node && (RECT_LIKE.has(node.shape) || node.shape === 'diamond')) return node
2242
- return null
2243
- }
2244
- const src = rectFor(edge.source)
2245
- const tgt = rectFor(edge.target)
2246
- if (!src || !tgt) return false
2247
-
2248
- // Already a straight border-to-border lane: nothing to repair.
2249
- const simplified = simplifyPolyline(edge.points)
2250
- if (simplified.length === 2 &&
2251
- onRectPerimeter(simplified[0]!, src.x, src.y, src.width, src.height, 1) &&
2252
- onRectPerimeter(simplified[1]!, tgt.x, tgt.y, tgt.width, tgt.height, 1)) {
2253
- return false
2254
- }
2255
-
2256
- // The lane axis comes from how the rects are separated, not the graph
2257
- // direction — a direction-override parent can stack siblings either way.
2258
- const gaps: Array<{ gap: number; axis: Axis }> = [
2259
- { gap: tgt.y - (src.y + src.height), axis: { main: 'y', cross: 'x', sign: 1 } },
2260
- { gap: src.y - (tgt.y + tgt.height), axis: { main: 'y', cross: 'x', sign: -1 } },
2261
- { gap: tgt.x - (src.x + src.width), axis: { main: 'x', cross: 'y', sign: 1 } },
2262
- { gap: src.x - (tgt.x + tgt.width), axis: { main: 'x', cross: 'y', sign: -1 } },
2263
- ]
2264
- const best = gaps.filter(g => g.gap > EPS).sort((a, b) => b.gap - a.gap)[0]
2265
- if (!best) return false
2266
-
2267
- return tryStraighten(edge, src, tgt, ctx, best.axis).applied
2268
- }
2269
-
2270
- // ============================================================================
2271
- // Route audit — the remaining issue #25 Phase 1 warning codes. Each check is
2272
- // a tripwire: the pipeline upholds these invariants itself, so any finding
2273
- // means a later pass broke geometry after certification. All definitions are
2274
- // chosen to be zero-noise on the corpus.
2275
- // ============================================================================
2276
-
2277
- export type RouteAuditFinding =
2278
- | { code: 'ROUTE_UNEXPLAINED_BEND'; edge: string }
2279
- | { code: 'ROUTE_LABEL_ON_SHARED_TRUNK'; edge: string; sharedWith: string }
2280
- | { code: 'ROUTE_CONTAINER_MISANCHOR'; edge: string; container: string }
2281
- | { code: 'ROUTE_SHAPE_MISANCHOR'; edge: string; node: string }
2282
- | { code: 'ROUTE_STALE_AFTER_NODE_MOVE'; edge: string; node: string }
2283
-
2284
- /** Within `tol` of the rectangle's perimeter (on an edge line, inside its range). */
2285
- function onRectPerimeter(p: Point, x: number, y: number, w: number, h: number, tol: number): boolean {
2286
- const onVertical = (Math.abs(p.x - x) <= tol || Math.abs(p.x - (x + w)) <= tol) &&
2287
- p.y >= y - tol && p.y <= y + h + tol
2288
- const onHorizontal = (Math.abs(p.y - y) <= tol || Math.abs(p.y - (y + h)) <= tol) &&
2289
- p.x >= x - tol && p.x <= x + w + tol
2290
- return onVertical || onHorizontal
2291
- }
2292
-
2293
- function flattenGroups(groups: PositionedGroup[], out: Map<string, PositionedGroup> = new Map()): Map<string, PositionedGroup> {
2294
- for (const g of groups) {
2295
- out.set(g.id, g)
2296
- flattenGroups(g.children, out)
2297
- }
2298
- return out
2299
- }
2300
-
2301
- /**
2302
- * The other edge whose collinear segment shares this labeled edge's pill trunk
2303
- * (spec §11.4) — the exact condition ROUTE_LABEL_ON_SHARED_TRUNK audits, lifted
2304
- * into a predicate so the audit and the label-repair pass agree by construction:
2305
- * the repair fixes precisely what the audit flags. Returns the conflicting edge,
2306
- * or null when the label is unambiguous.
2307
- */
2308
- function sharedTrunkConflict(
2309
- edge: PositionedEdge,
2310
- edges: PositionedEdge[],
2311
- style: LabelMetricsStyle,
2312
- ): PositionedEdge | null {
2313
- if (!edge.label || !edge.labelPosition) return null
2314
- const m = measureMultilineText(applyTextTransform(edge.label, style.edgeTextTransform), style.edgeLabelFontSize, style.edgeLabelFontWeight)
2315
- const pill = pillRect(edge.labelPosition.x, edge.labelPosition.y, m)
2316
- for (const other of edges) {
2317
- if (other === edge || (edge.edgeIndex !== undefined && other.edgeIndex === edge.edgeIndex)) continue
2318
- for (let i = 1; i < other.points.length; i++) {
2319
- const a = other.points[i - 1]!, b = other.points[i]!
2320
- const vertical = Math.abs(a.x - b.x) < EPS
2321
- const horizontal = Math.abs(a.y - b.y) < EPS
2322
- if (!vertical && !horizontal) continue
2323
- const sxLo = Math.min(a.x, b.x), sxHi = Math.max(a.x, b.x)
2324
- const syLo = Math.min(a.y, b.y), syHi = Math.max(a.y, b.y)
2325
- const hitsPill = sxHi >= pill.x && sxLo <= pill.x + pill.w && syHi >= pill.y && syLo <= pill.y + pill.h
2326
- if (!hitsPill) continue
2327
- // Shared trunk only when one of THIS edge's segments is collinear with the
2328
- // other's; a plain perpendicular crossing is not.
2329
- for (let j = 1; j < edge.points.length; j++) {
2330
- const c = edge.points[j - 1]!, d = edge.points[j]!
2331
- const sameAxis = vertical
2332
- ? Math.abs(c.x - d.x) < EPS && Math.abs(c.x - a.x) < CLEARANCE
2333
- : Math.abs(c.y - d.y) < EPS && Math.abs(c.y - a.y) < CLEARANCE
2334
- if (!sameAxis) continue
2335
- const cLo = vertical ? Math.min(c.y, d.y) : Math.min(c.x, d.x)
2336
- const cHi = vertical ? Math.max(c.y, d.y) : Math.max(c.x, d.x)
2337
- const oLo = vertical ? syLo : sxLo
2338
- const oHi = vertical ? syHi : sxHi
2339
- if (Math.min(cHi, oHi) - Math.max(cLo, oLo) > EPS) return other
2340
- }
2341
- }
2342
- }
2343
- return null
2344
- }
2345
-
2346
- /**
2347
- * Post-freeze label repair for ROUTE_LABEL_ON_SHARED_TRUNK. A labeled edge whose
2348
- * pill sits on a trunk shared with another edge is illegible — the reader cannot
2349
- * tell which edge the label names. An earlier pass can place such a label on a
2350
- * congested segment (e.g. a labeled edge running alongside its fan-out siblings),
2351
- * and applyRouteContracts only re-slots labels on edges it re-routes, so these
2352
- * survive to the output. Here we re-run the clearance-aware findLabelSlot over
2353
- * the edge's own axis-aligned segments (longest first — most room) and move ONLY
2354
- * the label to the first position clear of every node and foreign segment.
2355
- *
2356
- * Geometry-preserving (labels only) so it is freeze-safe; deterministic (fixed
2357
- * segment order + findLabelSlot's fixed candidate set). When no clear slot exists
2358
- * — e.g. an exactly-overlapping duplicate edge shares the whole trunk — the label
2359
- * is left untouched, since there is nowhere unambiguous to put it.
2360
- */
2361
- export function repairLabelsOnSharedTrunks(
2362
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[]; groups: PositionedGroup[] },
2363
- graph: MermaidGraph,
2364
- style: LabelMetricsStyle,
2365
- ): void {
2366
- // The repair only needs to clear the AUDIT condition (a collinear shared
2367
- // trunk) plus avoid drawing the pill over a node — NOT the stricter
2368
- // full-clearance findLabelSlot uses, which also rejects harmless
2369
- // perpendicular crossings and so leaves no slot in dense fan-in/out regions.
2370
- const pillOverNode = (pill: { x: number; y: number; w: number; h: number }, edge: PositionedEdge): boolean =>
2371
- positioned.nodes.some(n => n.id !== edge.source && n.id !== edge.target &&
2372
- pill.x < n.x + n.width && pill.x + pill.w > n.x && pill.y < n.y + n.height && pill.y + pill.h > n.y)
2373
-
2374
- for (const edge of positioned.edges) {
2375
- if (!edge.label || !edge.labelPosition || edge.points.length < 2) continue
2376
- if (!sharedTrunkConflict(edge, positioned.edges, style)) continue
2377
- const m = measureMultilineText(applyTextTransform(edge.label, style.edgeTextTransform), style.edgeLabelFontSize, style.edgeLabelFontWeight)
2378
- const pw = m.width + 2 * LABEL_PILL_PADDING, ph = m.height + 2 * LABEL_PILL_PADDING
2379
-
2380
- const segs: Array<{ a: Point; b: Point; vertical: boolean; len: number; i: number }> = []
2381
- for (let i = 1; i < edge.points.length; i++) {
2382
- const a = edge.points[i - 1]!, b = edge.points[i]!
2383
- const vertical = Math.abs(a.x - b.x) < EPS
2384
- if (vertical || Math.abs(a.y - b.y) < EPS) segs.push({ a, b, vertical, len: Math.abs(a.x - b.x) + Math.abs(a.y - b.y), i })
2385
- }
2386
- segs.sort((p, q) => q.len - p.len || p.i - q.i)
2387
-
2388
- const saved = edge.labelPosition
2389
- let fixed = false
2390
- for (const seg of segs) {
2391
- if (fixed) break
2392
- const half = (seg.vertical ? ph : pw) / 2
2393
- if (seg.len <= 2 * half) continue // pill can't sit on this segment without overhanging a bend
2394
- const tMargin = half / seg.len
2395
- // Sample the fitting interval, ordered center-out (the midpoint reads best).
2396
- const N = 11
2397
- const ts: number[] = []
2398
- for (let k = 0; k < N; k++) ts.push(tMargin + (1 - 2 * tMargin) * (k / (N - 1)))
2399
- ts.sort((a, b) => Math.abs(a - 0.5) - Math.abs(b - 0.5))
2400
- for (const t of ts) {
2401
- const cx = seg.a.x + (seg.b.x - seg.a.x) * t
2402
- const cy = seg.a.y + (seg.b.y - seg.a.y) * t
2403
- edge.labelPosition = { x: cx, y: cy }
2404
- if (!pillOverNode(pillRect(cx, cy, m), edge) && !sharedTrunkConflict(edge, positioned.edges, style)) { fixed = true; break }
2405
- }
2406
- }
2407
-
2408
- if (!fixed) edge.labelPosition = saved // nowhere unambiguous on this route — leave as placed
2409
- }
2410
- }
2411
-
2412
- /**
2413
- * Post-freeze label repair for labels left OFF their own route. ELK can place an
2414
- * edge label with a perpendicular offset from the line; applyRouteContracts
2415
- * re-slots labels only on edges it RE-ROUTES, so a label on an edge that came out
2416
- * of ELK ALREADY straight (and is not a shared-trunk conflict, so the pass above
2417
- * skips it too) keeps ELK's offset placement — which the rubric flags as
2418
- * labelOffRoute once the offset exceeds half the pill height. Seen on a
2419
- * high-degree mixed-label fan-in, where a straight diamond→hub spoke's label sat
2420
- * a pill-height above its own line. Re-slot ONLY such labels onto their own
2421
- * axis-aligned segments (longest first, center-out — the midpoint reads best),
2422
- * taking the first slot that sits ON the route and clear of every node and
2423
- * foreign trunk. If none exists, leave the label as placed (a rare off-route
2424
- * label is a lesser evil than a pill drawn over a node).
2425
- *
2426
- * Self-loops are EXCLUDED: their label legitimately sits beside ELK's small loop
2427
- * stub (a pill-width from it), which is correct, not off-route. Geometry-
2428
- * preserving (labels only) so it is freeze-safe; deterministic (fixed segment
2429
- * order + fixed center-out sample). A no-op on the HARD-clean corpus, whose
2430
- * labels are already on their routes.
2431
- */
2432
- export function repairLabelsOffOwnRoute(
2433
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[]; groups: PositionedGroup[] },
2434
- graph: MermaidGraph,
2435
- style: LabelMetricsStyle,
2436
- ): void {
2437
- const distToSeg = (p: Point, a: Point, b: Point): number => {
2438
- const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy
2439
- if (l2 === 0) return Math.hypot(p.x - a.x, p.y - a.y)
2440
- const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2))
2441
- return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
2442
- }
2443
- const pillOverNode = (pill: { x: number; y: number; w: number; h: number }, edge: PositionedEdge): boolean =>
2444
- positioned.nodes.some(n => n.id !== edge.source && n.id !== edge.target &&
2445
- pill.x < n.x + n.width && pill.x + pill.w > n.x && pill.y < n.y + n.height && pill.y + pill.h > n.y)
2446
-
2447
- for (const edge of positioned.edges) {
2448
- if (!edge.label || !edge.labelPosition || edge.points.length < 2) continue
2449
- if (edge.source === edge.target) continue // self-loop label sits beside the stub — correct, not off-route
2450
- const m = measureMultilineText(applyTextTransform(edge.label, style.edgeTextTransform), style.edgeLabelFontSize, style.edgeLabelFontWeight)
2451
- const pw = m.width + 2 * LABEL_PILL_PADDING, ph = m.height + 2 * LABEL_PILL_PADDING
2452
- const allow = ph / 2 + 4 // exactly the rubric's labelOffRoute allowance
2453
- const routeDist = (p: Point): number => {
2454
- let d = Infinity
2455
- for (let i = 1; i < edge.points.length; i++) d = Math.min(d, distToSeg(p, edge.points[i - 1]!, edge.points[i]!))
2456
- return d
2457
- }
2458
- if (routeDist(edge.labelPosition) <= allow) continue // already on its own route — no-op (the corpus case)
2459
- const clearAt = (x: number, y: number): boolean => routeDist({ x, y }) <= allow &&
2460
- !pillOverNode(pillRect(x, y, m), edge) &&
2461
- !sharedTrunkConflict({ ...edge, labelPosition: { x, y } }, positioned.edges, style)
2462
-
2463
- const segs: Array<{ a: Point; b: Point; vertical: boolean; len: number; i: number }> = []
2464
- for (let i = 1; i < edge.points.length; i++) {
2465
- const a = edge.points[i - 1]!, b = edge.points[i]!
2466
- const vertical = Math.abs(a.x - b.x) < EPS
2467
- if (vertical || Math.abs(a.y - b.y) < EPS) segs.push({ a, b, vertical, len: Math.abs(a.x - b.x) + Math.abs(a.y - b.y), i })
2468
- }
2469
- segs.sort((p, q) => q.len - p.len || p.i - q.i)
2470
-
2471
- // (1) Prefer a slot ON the route: sample each segment center-out (midpoint
2472
- // reads best) and take the first clear one.
2473
- let placed = false
2474
- for (const seg of segs) {
2475
- if (placed) break
2476
- const half = (seg.vertical ? ph : pw) / 2
2477
- if (seg.len <= 2 * half) continue // pill can't sit on this segment without overhanging a bend
2478
- const tMargin = half / seg.len
2479
- const N = 11
2480
- const ts: number[] = []
2481
- for (let k = 0; k < N; k++) ts.push(tMargin + (1 - 2 * tMargin) * (k / (N - 1)))
2482
- ts.sort((a, b) => Math.abs(a - 0.5) - Math.abs(b - 0.5))
2483
- for (const t of ts) {
2484
- const cx = seg.a.x + (seg.b.x - seg.a.x) * t
2485
- const cy = seg.a.y + (seg.b.y - seg.a.y) * t
2486
- if (clearAt(cx, cy)) { edge.labelPosition = { x: cx, y: cy }; placed = true; break }
2487
- }
2488
- }
2489
- // (2) No on-route slot (the route's approach is a shared/congested trunk, so
2490
- // every on-route position collides). Pull the label PERPENDICULARLY toward
2491
- // the route — from wherever ELK parked it — to just inside the allowance
2492
- // (allow - 1), which still clears the trunk by ~half the remaining gap. Keep
2493
- // ELK's side first; fall back to the mirror side. This turns an off-route
2494
- // label into a within-tolerance one without forcing it onto a busy trunk.
2495
- if (!placed) {
2496
- let foot = edge.points[0]!, fd = Infinity
2497
- for (const s of segs.length ? segs : []) {
2498
- const dx = s.b.x - s.a.x, dy = s.b.y - s.a.y, l2 = dx * dx + dy * dy || 1
2499
- const t = Math.max(0, Math.min(1, ((edge.labelPosition.x - s.a.x) * dx + (edge.labelPosition.y - s.a.y) * dy) / l2))
2500
- const fx = s.a.x + t * dx, fy = s.a.y + t * dy, d = Math.hypot(edge.labelPosition.x - fx, edge.labelPosition.y - fy)
2501
- if (d < fd) { fd = d; foot = { x: fx, y: fy } }
2502
- }
2503
- const ux = fd > EPS ? (edge.labelPosition.x - foot.x) / fd : 0
2504
- const uy = fd > EPS ? (edge.labelPosition.y - foot.y) / fd : -1 // default: lift above
2505
- const off = allow - 1
2506
- for (const side of [1, -1]) {
2507
- const cx = foot.x + ux * off * side, cy = foot.y + uy * off * side
2508
- if (clearAt(cx, cy)) { edge.labelPosition = { x: cx, y: cy }; break }
2509
- }
2510
- }
2511
- }
2512
- }
2513
-
2514
- /**
2515
- * Post-freeze label repair for pill-vs-pill collisions ACROSS edges. The
2516
- * shared-trunk repair separates labels whose ROUTES share a trunk; parallel
2517
- * edges that were split into adjacent lanes have distinct routes, but every
2518
- * label still sits at its own route midpoint — the midpoints are one lane gap
2519
- * apart while a pill is ~3 gaps tall, so the pills stack (the 2026-07 overlap
2520
- * audit: 70% of fuzzed state diagrams, plus curated corpus state/flowchart
2521
- * cases — reciprocal `start`/`cancel` pairs, feedback `pass`/`fail` pairs).
2522
- * Resolution slides the LATER edge's label along its OWN route (labelOffRoute
2523
- * stays satisfied by construction) to the nearest slot whose pill clears every
2524
- * other pill and every node; if the later edge has no clear slot, the earlier
2525
- * edge tries; when neither clears, both stay (honest residual, surfaced by
2526
- * eval/overlap-audit). Fires only on measured pill intersection, so
2527
- * label-clean layouts are byte-identical.
2528
- */
2529
- export function separateEdgeLabelPills(
2530
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[]; groups: PositionedGroup[] },
2531
- graph: MermaidGraph,
2532
- style: LabelMetricsStyle = resolveRenderStyle({}),
2533
- ): void {
2534
- const labeled = positioned.edges.filter(e => e.label && e.labelPosition && e.points.length >= 2)
2535
- if (labeled.length < 2) return
2536
- // The renderer's halo pads the pill by a further max(4, 2×strokeWidth) per
2537
- // side (renderEdgeLabel); separating inner pills can still leave the DRAWN
2538
- // halos touching. Inflate by the thick-edge worst case so cleared means
2539
- // visually cleared.
2540
- const HALO = 8
2541
- const rectOf = (e: PositionedEdge): { x: number; y: number; w: number; h: number } | null => {
2542
- const r = labelRect(e, style)
2543
- return r && { x: r.x - HALO, y: r.y - HALO, w: r.w + 2 * HALO, h: r.h + 2 * HALO }
2544
- }
2545
- const intersects = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean =>
2546
- Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0.5 &&
2547
- Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5
2548
-
2549
- // Unlike the other label repairs, do NOT exempt the edge's own endpoints: a
2550
- // slid label that clears its sibling pill by parking its halo on the target
2551
- // node has traded one occlusion for another (the audit's state-corpus case).
2552
- const haloOverAnyNode = (inner: { x: number; y: number; w: number; h: number }): boolean =>
2553
- positioned.nodes.some(n =>
2554
- inner.x - HALO < n.x + n.width && inner.x + inner.w + HALO > n.x &&
2555
- inner.y - HALO < n.y + n.height && inner.y + inner.h + HALO > n.y)
2556
-
2557
- const trySlide = (edge: PositionedEdge, others: PositionedEdge[]): boolean => {
2558
- const m = measureMultilineText(applyTextTransform(edge.label!, style.edgeTextTransform), style.edgeLabelFontSize, style.edgeLabelFontWeight)
2559
- const pw = m.width + 2 * LABEL_PILL_PADDING, ph = m.height + 2 * LABEL_PILL_PADDING
2560
- const clearAt = (x: number, y: number): boolean => {
2561
- const inner = pillRect(x, y, m)
2562
- if (haloOverAnyNode(inner)) return false
2563
- const pill = { x: inner.x - HALO, y: inner.y - HALO, w: inner.w + 2 * HALO, h: inner.h + 2 * HALO }
2564
- for (const o of others) {
2565
- const r = rectOf(o)
2566
- if (r && intersects(pill, r)) return false
2567
- }
2568
- return true
2569
- }
2570
- const segs: Array<{ a: Point; b: Point; vertical: boolean; len: number; i: number }> = []
2571
- for (let i = 1; i < edge.points.length; i++) {
2572
- const a = edge.points[i - 1]!, b = edge.points[i]!
2573
- const vertical = Math.abs(a.x - b.x) < EPS
2574
- if (vertical || Math.abs(a.y - b.y) < EPS) segs.push({ a, b, vertical, len: Math.abs(a.x - b.x) + Math.abs(a.y - b.y), i })
2575
- }
2576
- segs.sort((p, q) => q.len - p.len || p.i - q.i)
2577
- // Offset ladder: on-route first, then perpendicular offsets up to the
2578
- // rubric's labelOffRoute allowance (ph/2 + 4, the same formula
2579
- // repairLabelsOffOwnRoute enforces) — a labeled reciprocal pair whose
2580
- // lanes sit closer than two pill widths has NO on-route solution, but
2581
- // ±(allow-1) of lateral headroom per label is enough to part them while
2582
- // labelOffRoute stays satisfied by construction.
2583
- const allow = ph / 2 + 4
2584
- for (const d of [0, allow - 1, -(allow - 1), (allow - 1) / 2, -(allow - 1) / 2]) {
2585
- for (const seg of segs) {
2586
- const half = (seg.vertical ? ph : pw) / 2
2587
- if (seg.len <= 2 * half) continue // pill can't sit here without overhanging a bend
2588
- const nx = seg.vertical ? 1 : 0, ny = seg.vertical ? 0 : 1 // unit perpendicular
2589
- const tMargin = half / seg.len
2590
- const ts: number[] = []
2591
- const N = 11
2592
- for (let k = 0; k < N; k++) ts.push(tMargin + (1 - 2 * tMargin) * (k / (N - 1)))
2593
- ts.sort((a, b) => Math.abs(a - 0.5) - Math.abs(b - 0.5))
2594
- for (const t of ts) {
2595
- const cx = seg.a.x + (seg.b.x - seg.a.x) * t + nx * d
2596
- const cy = seg.a.y + (seg.b.y - seg.a.y) * t + ny * d
2597
- if (clearAt(cx, cy)) { edge.labelPosition = { x: cx, y: cy }; return true }
2598
- }
2599
- }
2600
- }
2601
- return false
2602
- }
2603
-
2604
- // Up to three sweeps: resolving one pair can expose (or clear) another; the
2605
- // slide target is always a fully-clear slot, so sweeps strictly reduce the
2606
- // conflict set and the cap is a defensive bound, not an expected exit.
2607
- for (let sweep = 0; sweep < 3; sweep++) {
2608
- let conflicts = 0
2609
- for (let i = 0; i < labeled.length; i++) {
2610
- for (let j = i + 1; j < labeled.length; j++) {
2611
- const a = labeled[i]!, b = labeled[j]!
2612
- const ra = rectOf(a), rb = rectOf(b)
2613
- if (!ra || !rb || !intersects(ra, rb)) continue
2614
- conflicts++
2615
- if (!trySlide(b, labeled.filter(e => e !== b))) trySlide(a, labeled.filter(e => e !== a))
2616
- }
2617
- }
2618
- if (conflicts === 0) break
2619
- }
2620
- }
2621
-
2622
- export function auditRouteContracts(
2623
- positioned: { nodes: PositionedNode[]; edges: PositionedEdge[]; groups: PositionedGroup[] },
2624
- graph: MermaidGraph,
2625
- style: LabelMetricsStyle = resolveRenderStyle({}),
2626
- ): RouteAuditFinding[] {
2627
- const findings: RouteAuditFinding[] = []
2628
- const nodeMap = new Map(positioned.nodes.map(n => [n.id, n]))
2629
- const groupMap = flattenGroups(positioned.groups)
2630
- const TOL = 1
2631
-
2632
- for (const edge of positioned.edges) {
2633
- if (edge.points.length < 2) continue
2634
- const cert = edge.routeCertificate
2635
- const id = edgeId(edge)
2636
-
2637
- // ROUTE_UNEXPLAINED_BEND: orthogonal routing produced a diagonal segment —
2638
- // a bend that neither the prover nor a blocker list can account for.
2639
- if (cert && (cert.routeClass === 'primary-forward' || cert.routeClass === 'feedback')) {
2640
- for (let i = 1; i < edge.points.length; i++) {
2641
- const a = edge.points[i - 1]!, b = edge.points[i]!
2642
- if (Math.abs(a.x - b.x) > EPS && Math.abs(a.y - b.y) > EPS) {
2643
- findings.push({ code: 'ROUTE_UNEXPLAINED_BEND', edge: id })
2644
- break
2645
- }
2646
- }
2647
- }
2648
-
2649
- // ROUTE_CONTAINER_MISANCHOR: a container edge must terminate on the
2650
- // container's border, not on a phantom node or a child (spec §11.5).
2651
- if (cert?.routeClass === 'container' && cert.edgeIndex >= 0) {
2652
- const graphEdge = graph.edges[cert.edgeIndex]
2653
- if (graphEdge) {
2654
- for (const [endId, point] of [
2655
- [graphEdge.source, edge.points[0]!],
2656
- [graphEdge.target, edge.points[edge.points.length - 1]!],
2657
- ] as const) {
2658
- const group = groupMap.get(endId)
2659
- if (group && !onRectPerimeter(point, group.x, group.y, group.width, group.height, TOL)) {
2660
- findings.push({ code: 'ROUTE_CONTAINER_MISANCHOR', edge: id, container: endId })
2661
- }
2662
- }
2663
- }
2664
- }
2665
-
2666
- // ROUTE_SHAPE_MISANCHOR / ROUTE_STALE_AFTER_NODE_MOVE: endpoints must sit
2667
- // on the rendered boundary of the shapes we have anchor contracts for
2668
- // (spec §11.6), and may never detach from their node entirely.
2669
- if (cert && cert.routeClass !== 'container') {
2670
- for (const [nodeId, point] of [
2671
- [edge.source, edge.points[0]!],
2672
- [edge.target, edge.points[edge.points.length - 1]!],
2673
- ] as const) {
2674
- const node = nodeMap.get(nodeId)
2675
- if (!node) continue
2676
- const inflated = point.x >= node.x - 2 && point.x <= node.x + node.width + 2 &&
2677
- point.y >= node.y - 2 && point.y <= node.y + node.height + 2
2678
- if (!inflated) {
2679
- findings.push({ code: 'ROUTE_STALE_AFTER_NODE_MOVE', edge: id, node: nodeId })
2680
- continue
2681
- }
2682
- if (node.shape === 'diamond') {
2683
- const dx = Math.abs(point.x - (node.x + node.width / 2)) / (node.width / 2)
2684
- const dy = Math.abs(point.y - (node.y + node.height / 2)) / (node.height / 2)
2685
- if (Math.abs(dx + dy - 1) > 0.03) {
2686
- findings.push({ code: 'ROUTE_SHAPE_MISANCHOR', edge: id, node: nodeId })
2687
- }
2688
- } else if (RECT_LIKE.has(node.shape)) {
2689
- if (!onRectPerimeter(point, node.x, node.y, node.width, node.height, TOL)) {
2690
- findings.push({ code: 'ROUTE_SHAPE_MISANCHOR', edge: id, node: nodeId })
2691
- }
2692
- }
2693
- }
2694
- }
2695
-
2696
- // ROUTE_STALE_AFTER_NODE_MOVE also catches the other stale-corridor
2697
- // signature: a non-incident node moved onto an already-certified route.
2698
- // Endpoint detachment above catches moved endpoints; this catches moved
2699
- // obstacles in the middle of a route.
2700
- for (const node of positioned.nodes) {
2701
- if (node.id === edge.source || node.id === edge.target) continue
2702
- for (let i = 1; i < edge.points.length; i++) {
2703
- const a = edge.points[i - 1]!, b = edge.points[i]!
2704
- const xLo = Math.min(a.x, b.x), xHi = Math.max(a.x, b.x)
2705
- const yLo = Math.min(a.y, b.y), yHi = Math.max(a.y, b.y)
2706
- if (xHi > node.x + 0.5 && xLo < node.x + node.width - 0.5 &&
2707
- yHi > node.y + 0.5 && yLo < node.y + node.height - 0.5) {
2708
- findings.push({ code: 'ROUTE_STALE_AFTER_NODE_MOVE', edge: id, node: node.id })
2709
- break
2710
- }
2711
- }
2712
- }
2713
-
2714
- // ROUTE_LABEL_ON_SHARED_TRUNK: a label pill sitting on a piece of line
2715
- // that another edge's collinear segment shares (spec §11.4) — the reader
2716
- // cannot tell which edge the label belongs to.
2717
- if (edge.label && edge.labelPosition) {
2718
- const other = sharedTrunkConflict(edge, positioned.edges, style)
2719
- if (other) findings.push({ code: 'ROUTE_LABEL_ON_SHARED_TRUNK', edge: id, sharedWith: edgeId(other) })
2720
- }
2721
- }
2722
- return findings
2723
- }