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