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