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