agentic-mermaid 0.1.1 → 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 +144 -4
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +25 -32
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/agent-core.d.ts +463 -0
- package/dist/agent-core.js +397 -0
- package/dist/agent.d.ts +73 -1868
- package/dist/agent.js +336 -37325
- package/dist/agentic-mermaid-mcp.js +7 -38399
- package/dist/am.js +33632 -40445
- 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 -33014
- 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 +68 -43
- package/server.json +2 -2
- package/skills/agentic-mermaid-diagram-workflow/SKILL.md +17 -36
- package/skills/agentic-mermaid-diagram-workflow/references/cli.md +4 -5
- package/skills/agentic-mermaid-diagram-workflow/references/code-mode.md +15 -31
- package/skills/agentic-mermaid-diagram-workflow/references/sequence.md +10 -14
- package/skills/agentic-mermaid-live-editor/SKILL.md +100 -206
- package/AGENT_NATIVE.md +0 -607
- package/Instructions_for_agents.md +0 -89
- package/TODO.md +0 -684
- package/assets/hero.png +0 -0
- package/dist/index-wjRjbsP-.d.ts +0 -1102
- 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 -211
- 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 -82
- 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-experience-curve-implementation.png +0 -0
- package/docs/design/families/journey-mermaid-classic-implementation.png +0 -0
- package/docs/design/families/journey-mermaid-classic-prototype.png +0 -0
- package/docs/design/families/journey-mermaid-classic-prototype.svg +0 -163
- package/docs/design/families/journey-mermaid-classic-prototype.svg.png +0 -0
- package/docs/design/families/journey-migration-parity.md +0 -439
- package/docs/design/families/journey-section-overlap-after.png +0 -0
- package/docs/design/families/journey-section-overlap-before.png +0 -0
- package/docs/design/families/journey-usage-research.md +0 -423
- package/docs/design/families/journey.md +0 -153
- package/docs/design/families/xychart.md +0 -85
- package/docs/design/system/README.md +0 -45
- package/docs/design/system/abstraction-audit.md +0 -224
- package/docs/design/system/abstraction-recommendations.md +0 -655
- package/docs/design/system/architecture.mmd +0 -29
- package/docs/design/system/architecture.svg +0 -137
- package/docs/design/system/layout-guarantees-and-robustness.md +0 -351
- package/docs/design/system/layout-rubric.md +0 -138
- package/docs/design/system/route-contracts.md +0 -848
- package/docs/design/system/source-preservation-ladder.md +0 -52
- package/docs/design/system/styles-rollout.md +0 -216
- package/docs/design/system/ugly-layouts.md +0 -119
- package/docs/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 -498
- package/docs/layout-characterization/contact-sheet.md +0 -638
- package/docs/layout-characterization/properties.md +0 -281
- package/docs/layout-characterization/visual-quality.md +0 -160
- package/docs/layout-characterization/visual-snapshots/architecture.svg +0 -79
- package/docs/layout-characterization/visual-snapshots/class.svg +0 -60
- package/docs/layout-characterization/visual-snapshots/er.svg +0 -61
- package/docs/layout-characterization/visual-snapshots/flowchart.svg +0 -58
- package/docs/layout-characterization/visual-snapshots/gantt.svg +0 -52
- package/docs/layout-characterization/visual-snapshots/journey.svg +0 -98
- package/docs/layout-characterization/visual-snapshots/pie.svg +0 -36
- package/docs/layout-characterization/visual-snapshots/quadrant.svg +0 -47
- package/docs/layout-characterization/visual-snapshots/sequence.svg +0 -59
- package/docs/layout-characterization/visual-snapshots/state.svg +0 -52
- package/docs/layout-characterization/visual-snapshots/timeline.svg +0 -64
- package/docs/layout-characterization/visual-snapshots/xychart.svg +0 -97
- package/docs/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 -215
- 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 -368
- package/src/agent/er-body.ts +0 -224
- package/src/agent/facts.ts +0 -360
- package/src/agent/families-builtin.ts +0 -920
- package/src/agent/families.ts +0 -232
- package/src/agent/family-layouts.ts +0 -832
- package/src/agent/flowchart-body.ts +0 -372
- package/src/agent/flowchart-unsupported.ts +0 -243
- package/src/agent/font-coverage.ts +0 -288
- package/src/agent/gantt-body.ts +0 -495
- package/src/agent/index.ts +0 -10
- package/src/agent/journey-body.ts +0 -374
- package/src/agent/label-metrics.ts +0 -28
- package/src/agent/layout-to-rendered.ts +0 -63
- package/src/agent/mutate.ts +0 -97
- package/src/agent/mutation-ops.ts +0 -51
- package/src/agent/op-schema.ts +0 -443
- package/src/agent/parse.ts +0 -223
- package/src/agent/pie-body.ts +0 -270
- package/src/agent/png.ts +0 -171
- package/src/agent/quadrant-body.ts +0 -336
- package/src/agent/quality.ts +0 -363
- package/src/agent/readability-audit.ts +0 -92
- package/src/agent/rendered-route-audit.ts +0 -61
- package/src/agent/sequence-body.ts +0 -345
- package/src/agent/serialize.ts +0 -214
- package/src/agent/state-body.ts +0 -589
- package/src/agent/structural-count.ts +0 -150
- package/src/agent/timeline-body.ts +0 -280
- package/src/agent/trace-log.ts +0 -43
- package/src/agent/types.ts +0 -995
- package/src/agent/verify.ts +0 -603
- package/src/agent/xychart-body.ts +0 -472
- package/src/architecture/config.ts +0 -282
- package/src/architecture/layout.ts +0 -496
- package/src/architecture/parser.ts +0 -375
- package/src/architecture/renderer.ts +0 -611
- package/src/architecture/types.ts +0 -113
- package/src/ascii/ansi.ts +0 -423
- package/src/ascii/architecture.ts +0 -210
- package/src/ascii/canvas.ts +0 -443
- package/src/ascii/class-diagram.ts +0 -697
- package/src/ascii/converter.ts +0 -475
- package/src/ascii/draw.ts +0 -1693
- package/src/ascii/edge-bundling.ts +0 -328
- package/src/ascii/edge-routing.ts +0 -330
- package/src/ascii/er-diagram.ts +0 -439
- package/src/ascii/gantt.ts +0 -276
- package/src/ascii/grid.ts +0 -747
- package/src/ascii/index.ts +0 -196
- package/src/ascii/journey.ts +0 -169
- package/src/ascii/meta.ts +0 -244
- package/src/ascii/multiline-utils.ts +0 -80
- package/src/ascii/pathfinder.ts +0 -301
- package/src/ascii/pie.ts +0 -67
- package/src/ascii/quadrant.ts +0 -158
- package/src/ascii/reverse.ts +0 -216
- package/src/ascii/sequence.ts +0 -499
- package/src/ascii/shapes/circle.ts +0 -27
- package/src/ascii/shapes/corners.ts +0 -143
- package/src/ascii/shapes/diamond.ts +0 -27
- package/src/ascii/shapes/hexagon.ts +0 -27
- package/src/ascii/shapes/index.ts +0 -106
- package/src/ascii/shapes/rectangle.ts +0 -168
- package/src/ascii/shapes/rounded.ts +0 -27
- package/src/ascii/shapes/special.ts +0 -332
- package/src/ascii/shapes/stadium.ts +0 -110
- package/src/ascii/shapes/state.ts +0 -192
- package/src/ascii/shapes/types.ts +0 -73
- package/src/ascii/timeline.ts +0 -141
- package/src/ascii/types.ts +0 -312
- package/src/ascii/validate.ts +0 -120
- package/src/ascii/width.ts +0 -81
- package/src/ascii/wrap.ts +0 -131
- package/src/ascii/xychart.ts +0 -919
- package/src/browser.ts +0 -29
- package/src/class/layout.ts +0 -240
- package/src/class/parser.ts +0 -332
- package/src/class/renderer.ts +0 -638
- package/src/class/types.ts +0 -129
- package/src/cli/agent-instructions.ts +0 -1
- package/src/cli/am-bin.ts +0 -6
- package/src/cli/exit-codes.ts +0 -18
- package/src/cli/index.ts +0 -1132
- package/src/cli/init-agent.ts +0 -147
- package/src/cli/run-entrypoint.ts +0 -10
- package/src/color-resolver.ts +0 -152
- package/src/elk-instance.ts +0 -114
- package/src/er/layout.ts +0 -187
- package/src/er/parser.ts +0 -226
- package/src/er/renderer.ts +0 -792
- package/src/er/types.ts +0 -99
- package/src/family-rubric.ts +0 -425
- package/src/font-manifest.ts +0 -31
- package/src/gantt/layout.ts +0 -365
- package/src/gantt/parser.ts +0 -282
- package/src/gantt/pipeline.ts +0 -27
- package/src/gantt/renderer.ts +0 -414
- package/src/gantt/schedule.ts +0 -513
- package/src/gantt/types.ts +0 -255
- package/src/index.ts +0 -368
- package/src/journey/layout.ts +0 -554
- package/src/journey/parse-core.ts +0 -251
- package/src/journey/parser.ts +0 -101
- package/src/journey/renderer.ts +0 -917
- package/src/journey/types.ts +0 -144
- package/src/layout/geometry.ts +0 -182
- package/src/layout/pass.ts +0 -142
- package/src/layout/passes/index.ts +0 -3916
- package/src/layout-engine.ts +0 -1565
- package/src/layout-rubric.ts +0 -659
- package/src/mcp/artifacts.ts +0 -137
- package/src/mcp/dynamic-harness.ts +0 -31
- package/src/mcp/dynamic-modules.d.ts +0 -7
- package/src/mcp/facade.ts +0 -608
- package/src/mcp/harness-runtime.ts +0 -203
- package/src/mcp/hosted-server.ts +0 -468
- package/src/mcp/mcp-bin.ts +0 -3
- package/src/mcp/mcp-cli.ts +0 -82
- package/src/mcp/protocol.ts +0 -13
- package/src/mcp/sandbox.ts +0 -195
- package/src/mcp/sdk-decl.ts +0 -391
- package/src/mcp/server.ts +0 -362
- package/src/mcp/tool-surface.ts +0 -146
- package/src/mermaid-source.ts +0 -655
- package/src/multiline-utils.ts +0 -228
- package/src/parser.ts +0 -1031
- package/src/pie/layout.ts +0 -168
- package/src/pie/parser.ts +0 -136
- package/src/pie/renderer.ts +0 -234
- package/src/pie/types.ts +0 -82
- package/src/quadrant/layout.ts +0 -191
- package/src/quadrant/parser.ts +0 -183
- package/src/quadrant/renderer.ts +0 -322
- package/src/quadrant/types.ts +0 -113
- package/src/render-family-hooks.ts +0 -244
- package/src/renderer.ts +0 -1099
- package/src/route-contracts.ts +0 -2723
- package/src/scene/backend.ts +0 -76
- package/src/scene/fidelity.ts +0 -183
- package/src/scene/hybrid-backend.ts +0 -174
- package/src/scene/ir.ts +0 -181
- package/src/scene/marks.ts +0 -127
- package/src/scene/rough-backend.ts +0 -378
- package/src/scene/seed.ts +0 -46
- package/src/scene/style-registry.ts +0 -556
- package/src/sequence/layout.ts +0 -456
- package/src/sequence/parser.ts +0 -255
- package/src/sequence/renderer.ts +0 -604
- package/src/sequence/types.ts +0 -154
- package/src/shape-clipping.ts +0 -351
- package/src/shared/accessibility-directives.ts +0 -22
- package/src/shared/batched.ts +0 -61
- package/src/shared/color-math.ts +0 -93
- package/src/shared/svg-a11y.ts +0 -29
- package/src/shared/syntax-error.ts +0 -17
- package/src/shared/unicode-ranges.ts +0 -135
- package/src/styles.ts +0 -318
- package/src/svg-paths.ts +0 -28
- package/src/text-metrics.ts +0 -248
- package/src/theme.ts +0 -710
- package/src/timeline/layout.ts +0 -381
- package/src/timeline/parser.ts +0 -215
- package/src/timeline/renderer.ts +0 -538
- package/src/timeline/types.ts +0 -105
- package/src/types.ts +0 -462
- package/src/website-worker-modules.d.ts +0 -18
- package/src/xychart/axis-utils.ts +0 -109
- package/src/xychart/colors.ts +0 -124
- package/src/xychart/config.ts +0 -88
- package/src/xychart/layout.ts +0 -656
- package/src/xychart/parser.ts +0 -504
- package/src/xychart/renderer.ts +0 -640
- package/src/xychart/types.ts +0 -282
package/src/layout-rubric.ts
DELETED
|
@@ -1,659 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic layout-quality rubric (docs/design/system/layout-rubric.md).
|
|
3
|
-
*
|
|
4
|
-
* Computes the empirically validated graph-drawing aesthetics (Purchase 1997,
|
|
5
|
-
* 2002: crossings and bends are the strongest comprehension factors), the
|
|
6
|
-
* orthogonal-drawing tradition's bend/orthogonality criteria (Tamassia), the
|
|
7
|
-
* Kakoulis–Tollis edge-label criteria (a label must associate unambiguously
|
|
8
|
-
* with its own edge), and this project's route/port contracts — all from
|
|
9
|
-
* final geometry, with no human judgment. Hard metrics must be zero; soft
|
|
10
|
-
* metrics are reported for thresholds set per fixture class.
|
|
11
|
-
*
|
|
12
|
-
* The outline oracle is independent of the layout/clipping code paths: it
|
|
13
|
-
* reimplements each shape's rendered outline equation from the renderer's
|
|
14
|
-
* geometry, so a port or clipping regression cannot hide itself.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { MermaidGraph, Point, PositionedEdge, PositionedGraph, PositionedGroup, PositionedNode } from './types.ts'
|
|
18
|
-
import { diamondFacetPorts, findRouteHitches, PORT_EXACT, shapePorts } from './route-contracts.ts'
|
|
19
|
-
import { measureMultilineText } from './text-metrics.ts'
|
|
20
|
-
import { resolveRenderStyle } from './styles.ts'
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Evidence-based impact rank for a violation, mirroring the QualityBounds
|
|
24
|
-
* provenance (src/agent/quality.ts BOUND_PROVENANCE, grounded in Purchase
|
|
25
|
-
* 1997/2002): readability-destroying defects (edges through nodes, overlaps,
|
|
26
|
-
* crossings) rank above routing-shape defects (bends, diagonals, hitches),
|
|
27
|
-
* which rank above endpoint/label-placement cosmetics. Reports sort by this so
|
|
28
|
-
* the most-impactful violation is read first, not buried in declaration order.
|
|
29
|
-
*
|
|
30
|
-
* JUSTIFIED / SYMMETRIC BEND (not a defect): a bend that is part of a SYMMETRIC
|
|
31
|
-
* convergence — a fan-out/fan-in bundle, or a co-ranked mixed-label fan-in's
|
|
32
|
-
* converging dogleg — is "as good as" a straight line and is NOT penalized. The
|
|
33
|
-
* bend is structurally necessary to converge, and the symmetry it buys offsets
|
|
34
|
-
* the small bend cost; every reference layered drawer routes a fan that way, and
|
|
35
|
-
* our own fan-out emitter (applySymmetricFanoutEmissions) does too. So
|
|
36
|
-
* bundle-certified bends are excluded from totalBends/maxBendsPerEdge below — the
|
|
37
|
-
* SAME edges findRouteHitches treats as non-hitches, keeping the bend penalty and
|
|
38
|
-
* the HARD hitch-invariant in agreement. Only UNJUSTIFIED / lone bends still cost
|
|
39
|
-
* (an off-lane jog with a clear straight lane is a `hitches` HARD violation; a
|
|
40
|
-
* bend on a 'straight'-certified edge is an `unexplainedBends` HARD violation).
|
|
41
|
-
*/
|
|
42
|
-
export type RubricSeverity = 'primary' | 'secondary' | 'cosmetic'
|
|
43
|
-
|
|
44
|
-
const RUBRIC_SEVERITY: Record<string, RubricSeverity> = {
|
|
45
|
-
edgeThroughNode: 'primary',
|
|
46
|
-
nodeOverlaps: 'primary',
|
|
47
|
-
diagonalSegments: 'secondary',
|
|
48
|
-
unexplainedBends: 'secondary',
|
|
49
|
-
hitches: 'secondary',
|
|
50
|
-
offOutlineEndpoints: 'cosmetic',
|
|
51
|
-
labelOffRoute: 'cosmetic',
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const RUBRIC_SEVERITY_ORDER: Record<RubricSeverity, number> = { primary: 0, secondary: 1, cosmetic: 2 }
|
|
55
|
-
|
|
56
|
-
export function rubricSeverity(metric: string): RubricSeverity {
|
|
57
|
-
return RUBRIC_SEVERITY[metric] ?? 'secondary'
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface RubricViolation {
|
|
61
|
-
metric: string
|
|
62
|
-
detail: string
|
|
63
|
-
/** Evidence-based impact rank; reports are sorted by it. */
|
|
64
|
-
severity: RubricSeverity
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface RubricMetrics {
|
|
68
|
-
edges: number
|
|
69
|
-
/** HARD (0): edge endpoints must lie on the rendered shape outline. */
|
|
70
|
-
offOutlineEndpoints: number
|
|
71
|
-
/** HARD (0): orthogonal routing must not emit diagonal segments. */
|
|
72
|
-
diagonalSegments: number
|
|
73
|
-
/** HARD (0): an edge certified 'straight' may not have bends. */
|
|
74
|
-
unexplainedBends: number
|
|
75
|
-
/** HARD (0): no edge may bend while a clear direct lane exists for it. */
|
|
76
|
-
hitches: number
|
|
77
|
-
/** HARD (0): node bounding boxes may not overlap. */
|
|
78
|
-
nodeOverlaps: number
|
|
79
|
-
/** HARD (0): a label pill must sit on its own route (Kakoulis–Tollis). */
|
|
80
|
-
labelOffRoute: number
|
|
81
|
-
/** HARD (0): edge segments through a non-incident node's interior. */
|
|
82
|
-
edgeThroughNode: number
|
|
83
|
-
/** Purchase's strongest validated aesthetic — minimize. */
|
|
84
|
-
edgeCrossings: number
|
|
85
|
-
/** Purchase-validated — minimize. Excludes justified symmetric-convergence
|
|
86
|
-
* bends (bundle-certified fan-out/fan-in spokes): those are "as good as
|
|
87
|
-
* straight", the same edges findRouteHitches treats as non-hitches. */
|
|
88
|
-
totalBends: number
|
|
89
|
-
/** As totalBends, also excluding justified symmetric-convergence bends. */
|
|
90
|
-
maxBendsPerEdge: number
|
|
91
|
-
/** Fraction of edge ends sitting exactly on a canonical port. */
|
|
92
|
-
portEndpointRate: number
|
|
93
|
-
/** Fraction of edges with at least one end on a canonical port. */
|
|
94
|
-
portAnchoredEdgeRate: number
|
|
95
|
-
/** Largest cross-axis offset between a peer hub and its peer barycenter. */
|
|
96
|
-
peerBarycenterDelta: number
|
|
97
|
-
/** SOFT label-CENTRING metric: max over labelled edges of
|
|
98
|
-
* labelMidpointOffset (|projFrac(label) - 0.5|). 0 = every label centred on
|
|
99
|
-
* its route; →0.5 = a label jammed at an endpoint. This is the gap that let
|
|
100
|
-
* the symmetric-dogleg hugging through — labelOffRoute only proves a label is
|
|
101
|
-
* ON its route, never WELL-PLACED on it. 0 when no labelled edges. */
|
|
102
|
-
worstLabelOffset: number
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export interface RubricResult {
|
|
106
|
-
metrics: RubricMetrics
|
|
107
|
-
violations: RubricViolation[]
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Whether a point lies on the rendered outline of a shape, within `tol` px.
|
|
112
|
-
* Shapes without an exact oracle yet fall back to the bbox perimeter, which
|
|
113
|
-
* is exact at the four ports and approximate elsewhere.
|
|
114
|
-
*/
|
|
115
|
-
function onDesignatedPort(node: PositionedNode, p: Point): boolean {
|
|
116
|
-
const near = (q: Point) => Math.abs(q.x - p.x) <= 0.5 && Math.abs(q.y - p.y) <= 0.5
|
|
117
|
-
if ((Object.values(shapePorts(node)) as Point[]).some(near)) return true
|
|
118
|
-
if (node.shape === 'diamond' && (Object.values(diamondFacetPorts(node)) as Point[]).some(near)) return true
|
|
119
|
-
return false
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function onShapeOutline(node: PositionedNode, p: Point, tol = 1.5): boolean {
|
|
123
|
-
const cx = node.x + node.width / 2
|
|
124
|
-
const cy = node.y + node.height / 2
|
|
125
|
-
const hw = node.width / 2
|
|
126
|
-
const hh = node.height / 2
|
|
127
|
-
const dx = p.x - cx
|
|
128
|
-
const dy = p.y - cy
|
|
129
|
-
|
|
130
|
-
const onBboxPerimeter = (): boolean => {
|
|
131
|
-
const onV = Math.abs(Math.abs(dx) - hw) < tol && Math.abs(dy) <= hh + tol
|
|
132
|
-
const onH = Math.abs(Math.abs(dy) - hh) < tol && Math.abs(dx) <= hw + tol
|
|
133
|
-
return onV || onH
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
switch (node.shape) {
|
|
137
|
-
case 'diamond':
|
|
138
|
-
return Math.abs(Math.abs(dx) / hw + Math.abs(dy) / hh - 1) * Math.min(hw, hh) < tol
|
|
139
|
-
case 'circle':
|
|
140
|
-
case 'doublecircle':
|
|
141
|
-
case 'state-start':
|
|
142
|
-
case 'state-end':
|
|
143
|
-
return Math.abs(Math.hypot(dx / hw, dy / hh) - 1) * Math.min(hw, hh) < tol
|
|
144
|
-
case 'stadium': {
|
|
145
|
-
const r = hh
|
|
146
|
-
if (Math.abs(dx) <= hw - r) return Math.abs(Math.abs(dy) - hh) < tol
|
|
147
|
-
const ax = Math.abs(dx) - (hw - r)
|
|
148
|
-
return Math.abs(Math.hypot(ax, dy) - r) < tol
|
|
149
|
-
}
|
|
150
|
-
case 'hexagon': {
|
|
151
|
-
// Flat top/bottom between the corner insets, slanted tips (renderer:
|
|
152
|
-
// inset = h/4, E/W vertices at mid-height).
|
|
153
|
-
const inset = node.height / 4
|
|
154
|
-
if (Math.abs(dx) <= hw - inset) return Math.abs(Math.abs(dy) - hh) < tol
|
|
155
|
-
// Slanted edge from (hw - inset, ±hh) to (hw, 0):
|
|
156
|
-
const t = (Math.abs(dx) - (hw - inset)) / inset // 0 at corner, 1 at tip
|
|
157
|
-
if (t < -tol || t > 1 + tol) return false
|
|
158
|
-
return Math.abs(Math.abs(dy) - hh * (1 - t)) < tol * 2
|
|
159
|
-
}
|
|
160
|
-
case 'cylinder': {
|
|
161
|
-
const ry = 7 // renderer's cap radius
|
|
162
|
-
// Vertical walls between the caps:
|
|
163
|
-
if (p.y >= node.y + ry - tol && p.y <= node.y + node.height - ry + tol &&
|
|
164
|
-
Math.abs(Math.abs(dx) - hw) < tol) return true
|
|
165
|
-
// Top/bottom elliptical caps:
|
|
166
|
-
const capY = p.y < cy ? node.y + ry : node.y + node.height - ry
|
|
167
|
-
const ey = (p.y - capY) / ry
|
|
168
|
-
const ex = dx / hw
|
|
169
|
-
return Math.abs(Math.hypot(ex, ey) - 1) * Math.min(hw, ry) < tol * 2
|
|
170
|
-
}
|
|
171
|
-
case 'trapezoid':
|
|
172
|
-
case 'trapezoid-alt':
|
|
173
|
-
case 'lean-r':
|
|
174
|
-
case 'lean-l':
|
|
175
|
-
case 'asymmetric': {
|
|
176
|
-
// Exact polygon oracle: min point-to-segment distance over the
|
|
177
|
-
// rendered polygon's edges (renderer geometry, reimplemented here so a
|
|
178
|
-
// clipping regression cannot certify itself healthy).
|
|
179
|
-
const verts = slantedPolygonVertices(node)
|
|
180
|
-
let best = Infinity
|
|
181
|
-
for (let i = 0; i < verts.length; i++) {
|
|
182
|
-
best = Math.min(best, pointToSegmentDistance(p, verts[i]!, verts[(i + 1) % verts.length]!))
|
|
183
|
-
}
|
|
184
|
-
return best <= tol
|
|
185
|
-
}
|
|
186
|
-
default:
|
|
187
|
-
return onBboxPerimeter()
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Rendered polygon outlines of the slanted shapes, restated from the
|
|
193
|
-
* renderer's geometry (shear = w * 0.15 for the trapezoid/parallelogram
|
|
194
|
-
* family; the asymmetric flag indents its left point by 12px).
|
|
195
|
-
*/
|
|
196
|
-
function slantedPolygonVertices(node: PositionedNode): Point[] {
|
|
197
|
-
const { x, y, width: w, height: h } = node
|
|
198
|
-
const inset = w * 0.15
|
|
199
|
-
switch (node.shape) {
|
|
200
|
-
case 'trapezoid': // wider bottom
|
|
201
|
-
return [{ x: x + inset, y }, { x: x + w - inset, y }, { x: x + w, y: y + h }, { x, y: y + h }]
|
|
202
|
-
case 'trapezoid-alt': // wider top
|
|
203
|
-
return [{ x, y }, { x: x + w, y }, { x: x + w - inset, y: y + h }, { x: x + inset, y: y + h }]
|
|
204
|
-
case 'lean-r': // parallelogram leaning right
|
|
205
|
-
return [{ x: x + inset, y }, { x: x + w, y }, { x: x + w - inset, y: y + h }, { x, y: y + h }]
|
|
206
|
-
case 'lean-l': // parallelogram leaning left
|
|
207
|
-
return [{ x, y }, { x: x + w - inset, y }, { x: x + w, y: y + h }, { x: x + inset, y: y + h }]
|
|
208
|
-
default: // asymmetric: flag with a pointed left edge
|
|
209
|
-
return [{ x: x + 12, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x: x + 12, y: y + h }, { x, y: y + h / 2 }]
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Whether an axis-aligned segment passes through a node's rendered footprint
|
|
215
|
-
* (not merely its bbox — a route grazing the empty corner outside a circle's
|
|
216
|
-
* disk or a diamond's facet is legal). Half-pixel tolerance throughout.
|
|
217
|
-
*/
|
|
218
|
-
export function segmentThroughShape(a: Point, b: Point, node: PositionedNode): boolean {
|
|
219
|
-
const cx = node.x + node.width / 2
|
|
220
|
-
const cy = node.y + node.height / 2
|
|
221
|
-
const hw = node.width / 2
|
|
222
|
-
const hh = node.height / 2
|
|
223
|
-
const xLo = Math.min(a.x, b.x), xHi = Math.max(a.x, b.x)
|
|
224
|
-
const yLo = Math.min(a.y, b.y), yHi = Math.max(a.y, b.y)
|
|
225
|
-
const inBbox = xHi > node.x + 0.5 && xLo < node.x + node.width - 0.5 &&
|
|
226
|
-
yHi > node.y + 0.5 && yLo < node.y + node.height - 0.5
|
|
227
|
-
if (!inBbox) return false
|
|
228
|
-
|
|
229
|
-
// For curved/pointed shapes, occlusion means penetrating DEEPER than the
|
|
230
|
-
// stroke width (2px): a route that grazes a diamond tip or a circle's rim
|
|
231
|
-
// by a hairline is not a legible defect, and dense corridors sometimes
|
|
232
|
-
// provably admit nothing better. Rectangles stay strict: any interior
|
|
233
|
-
// incursion is visible against a straight side.
|
|
234
|
-
const GRAZE = 2.5
|
|
235
|
-
switch (node.shape) {
|
|
236
|
-
case 'circle':
|
|
237
|
-
case 'doublecircle':
|
|
238
|
-
case 'state-start':
|
|
239
|
-
case 'state-end': {
|
|
240
|
-
// Scale to a unit circle so ellipses work too, then test segment distance.
|
|
241
|
-
const sa = { x: (a.x - cx) / hw, y: (a.y - cy) / hh }
|
|
242
|
-
const sb = { x: (b.x - cx) / hw, y: (b.y - cy) / hh }
|
|
243
|
-
return pointToSegmentDistance({ x: 0, y: 0 }, sa, sb) < 1 - GRAZE / Math.min(hw, hh)
|
|
244
|
-
}
|
|
245
|
-
case 'stadium': {
|
|
246
|
-
const r = hh
|
|
247
|
-
const coreA = { x: cx - (hw - r), y: cy }
|
|
248
|
-
const coreB = { x: cx + (hw - r), y: cy }
|
|
249
|
-
return segmentToSegmentDistance(a, b, coreA, coreB) < r - GRAZE
|
|
250
|
-
}
|
|
251
|
-
case 'diamond':
|
|
252
|
-
case 'hexagon':
|
|
253
|
-
case 'trapezoid':
|
|
254
|
-
case 'trapezoid-alt':
|
|
255
|
-
case 'lean-r':
|
|
256
|
-
case 'lean-l':
|
|
257
|
-
case 'asymmetric': {
|
|
258
|
-
const raw = node.shape === 'diamond'
|
|
259
|
-
? [
|
|
260
|
-
{ x: cx, y: node.y }, { x: node.x + node.width, y: cy },
|
|
261
|
-
{ x: cx, y: node.y + node.height }, { x: node.x, y: cy },
|
|
262
|
-
]
|
|
263
|
-
: node.shape === 'hexagon'
|
|
264
|
-
? (() => {
|
|
265
|
-
const inset = node.height / 4
|
|
266
|
-
return [
|
|
267
|
-
{ x: node.x + inset, y: node.y }, { x: node.x + node.width - inset, y: node.y },
|
|
268
|
-
{ x: node.x + node.width, y: cy }, { x: node.x + node.width - inset, y: node.y + node.height },
|
|
269
|
-
{ x: node.x + inset, y: node.y + node.height }, { x: node.x, y: cy },
|
|
270
|
-
]
|
|
271
|
-
})()
|
|
272
|
-
: slantedPolygonVertices(node)
|
|
273
|
-
const shrink = Math.max(0, 1 - GRAZE / Math.min(hw, hh))
|
|
274
|
-
const verts = raw.map(v => ({ x: cx + (v.x - cx) * shrink, y: cy + (v.y - cy) * shrink }))
|
|
275
|
-
return segmentThroughConvexPolygon(a, b, verts)
|
|
276
|
-
}
|
|
277
|
-
default:
|
|
278
|
-
return true // rect-like (and cylinder, whose footprint is its bbox)
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function segmentThroughConvexPolygon(a: Point, b: Point, verts: Point[]): boolean {
|
|
283
|
-
const inside = (p: Point): boolean => {
|
|
284
|
-
for (let i = 0; i < verts.length; i++) {
|
|
285
|
-
const v1 = verts[i]!, v2 = verts[(i + 1) % verts.length]!
|
|
286
|
-
if ((v2.x - v1.x) * (p.y - v1.y) - (v2.y - v1.y) * (p.x - v1.x) < -0.5) return false
|
|
287
|
-
}
|
|
288
|
-
return true
|
|
289
|
-
}
|
|
290
|
-
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
|
291
|
-
if (inside(a) || inside(b) || inside(mid)) return true
|
|
292
|
-
for (let i = 0; i < verts.length; i++) {
|
|
293
|
-
if (segmentsIntersect(a, b, verts[i]!, verts[(i + 1) % verts.length]!)) return true
|
|
294
|
-
}
|
|
295
|
-
return false
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function segmentToSegmentDistance(a: Point, b: Point, c: Point, d: Point): number {
|
|
299
|
-
if (segmentsIntersect(a, b, c, d)) return 0
|
|
300
|
-
return Math.min(
|
|
301
|
-
pointToSegmentDistance(a, c, d), pointToSegmentDistance(b, c, d),
|
|
302
|
-
pointToSegmentDistance(c, a, b), pointToSegmentDistance(d, a, b),
|
|
303
|
-
)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function segmentsIntersect(a: Point, b: Point, c: Point, d: Point): boolean {
|
|
307
|
-
const det = (b.x - a.x) * (d.y - c.y) - (b.y - a.y) * (d.x - c.x)
|
|
308
|
-
if (Math.abs(det) < 1e-9) return false
|
|
309
|
-
const t = ((c.x - a.x) * (d.y - c.y) - (c.y - a.y) * (d.x - c.x)) / det
|
|
310
|
-
const u = ((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / det
|
|
311
|
-
return t > 0.001 && t < 0.999 && u > 0.001 && u < 0.999
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function pointToSegmentDistance(p: Point, a: Point, b: Point): number {
|
|
315
|
-
const dx = b.x - a.x
|
|
316
|
-
const dy = b.y - a.y
|
|
317
|
-
const lenSq = dx * dx + dy * dy
|
|
318
|
-
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
|
|
319
|
-
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
|
|
320
|
-
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Distance from an axis-aligned rectangle (a label pill) to a segment; 0 when
|
|
325
|
-
* they touch or overlap. Unlike point-to-segment from the pill CENTRE, this
|
|
326
|
-
* treats a label whose BODY meets the route as on-route — the correct test for
|
|
327
|
-
* self-loops, where ELK legitimately parks the label beside the small loop stub
|
|
328
|
-
* so the pill's near edge touches the stub while its centre sits a pill-width away.
|
|
329
|
-
*/
|
|
330
|
-
function rectToSegmentDistance(cx: number, cy: number, hw: number, hh: number, a: Point, b: Point): number {
|
|
331
|
-
const inside = (p: Point) => p.x >= cx - hw && p.x <= cx + hw && p.y >= cy - hh && p.y <= cy + hh
|
|
332
|
-
if (inside(a) || inside(b)) return 0
|
|
333
|
-
const corners: Point[] = [
|
|
334
|
-
{ x: cx - hw, y: cy - hh }, { x: cx + hw, y: cy - hh },
|
|
335
|
-
{ x: cx + hw, y: cy + hh }, { x: cx - hw, y: cy + hh },
|
|
336
|
-
]
|
|
337
|
-
let best = Infinity
|
|
338
|
-
for (let i = 0; i < 4; i++) best = Math.min(best, segmentToSegmentDistance(corners[i]!, corners[(i + 1) % 4]!, a, b))
|
|
339
|
-
return best
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Arc-length position (0..1) along a polyline of the closest point on the route
|
|
344
|
-
* to `pt` — the fractional projection of a label onto its own edge. 0 is the
|
|
345
|
-
* source end, 1 the target end, 0.5 the route's arc-length midpoint.
|
|
346
|
-
*/
|
|
347
|
-
function projFrac(pt: Point, pts: Point[]): number {
|
|
348
|
-
let total = 0
|
|
349
|
-
const seglen: number[] = []
|
|
350
|
-
for (let i = 1; i < pts.length; i++) {
|
|
351
|
-
const d = Math.hypot(pts[i]!.x - pts[i - 1]!.x, pts[i]!.y - pts[i - 1]!.y)
|
|
352
|
-
seglen.push(d); total += d
|
|
353
|
-
}
|
|
354
|
-
let best = Infinity, bestArc = 0, acc = 0
|
|
355
|
-
for (let i = 1; i < pts.length; i++) {
|
|
356
|
-
const a = pts[i - 1]!, b = pts[i]!, dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy
|
|
357
|
-
let t = l2 ? ((pt.x - a.x) * dx + (pt.y - a.y) * dy) / l2 : 0
|
|
358
|
-
t = Math.max(0, Math.min(1, t))
|
|
359
|
-
const px = a.x + t * dx, py = a.y + t * dy, d = Math.hypot(pt.x - px, pt.y - py)
|
|
360
|
-
if (d < best) { best = d; bestArc = acc + t * seglen[i - 1]! }
|
|
361
|
-
acc += seglen[i - 1]!
|
|
362
|
-
}
|
|
363
|
-
return total ? bestArc / total : 0.5
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Label-CENTRING metric (SOFT): how far a label's projection onto its own route
|
|
368
|
-
* is from the route midpoint. `labelMidpointOffset = |projFrac(labelPosition) -
|
|
369
|
-
* 0.5|`; 0 is perfectly centred, →0.5 is jammed against an endpoint.
|
|
370
|
-
*
|
|
371
|
-
* Why this is its own metric and not covered by labelOffRoute: labelOffRoute
|
|
372
|
-
* (HARD) only checks a label sits ON its route (Kakoulis–Tollis association),
|
|
373
|
-
* never WHERE along it. A symmetric re-route (co-rank fan-in / fan-out dogleg)
|
|
374
|
-
* can leave a label HARD-clean — exactly on its route — yet hugging an endpoint,
|
|
375
|
-
* which reads as belonging to the node rather than the edge. That hugging slips
|
|
376
|
-
* past labelOffRoute, the HARD gate, and the byte-exact equivalence gate. This
|
|
377
|
-
* metric is the missing centring check; dagre places edge labels at the route
|
|
378
|
-
* midpoint, so a well-placed label scores ≈0.
|
|
379
|
-
*/
|
|
380
|
-
export function labelMidpointOffset(edge: PositionedEdge): number {
|
|
381
|
-
if (!edge.label || !edge.labelPosition || edge.points.length < 2) return 0
|
|
382
|
-
return Math.abs(projFrac(edge.labelPosition, edge.points) - 0.5)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function nodeCrossCenter(node: PositionedNode, direction: MermaidGraph['direction']): number {
|
|
386
|
-
return direction === 'LR' || direction === 'RL'
|
|
387
|
-
? node.y + node.height / 2
|
|
388
|
-
: node.x + node.width / 2
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function nodeMainStart(node: PositionedNode, direction: MermaidGraph['direction']): number {
|
|
392
|
-
return direction === 'LR' || direction === 'RL' ? node.x : node.y
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function nodeMainEnd(node: PositionedNode, direction: MermaidGraph['direction']): number {
|
|
396
|
-
return direction === 'LR' || direction === 'RL'
|
|
397
|
-
? node.x + node.width
|
|
398
|
-
: node.y + node.height
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function forwardish(source: PositionedNode, target: PositionedNode, direction: MermaidGraph['direction']): boolean {
|
|
402
|
-
if (direction === 'LR') return nodeMainStart(target, direction) > nodeMainEnd(source, direction)
|
|
403
|
-
if (direction === 'RL') return nodeMainEnd(target, direction) < nodeMainStart(source, direction)
|
|
404
|
-
if (direction === 'BT') return nodeMainEnd(target, direction) < nodeMainStart(source, direction)
|
|
405
|
-
return nodeMainStart(target, direction) > nodeMainEnd(source, direction)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function samePeerLayer(nodes: PositionedNode[], direction: MermaidGraph['direction'], tolerance = 1): boolean {
|
|
409
|
-
if (nodes.length < 2) return false
|
|
410
|
-
const starts = nodes.map(node => nodeMainStart(node, direction))
|
|
411
|
-
return Math.max(...starts) - Math.min(...starts) <= tolerance
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function nodeInsideGroups(node: PositionedNode, groups: PositionedGroup[]): boolean {
|
|
415
|
-
const visit = (items: PositionedGroup[]): boolean => items.some(group =>
|
|
416
|
-
(node.x >= group.x - 0.5 && node.y >= group.y - 0.5 &&
|
|
417
|
-
node.x + node.width <= group.x + group.width + 0.5 &&
|
|
418
|
-
node.y + node.height <= group.y + group.height + 0.5) || visit(group.children))
|
|
419
|
-
return visit(groups)
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function logicalGraphReaches(graph: MermaidGraph, from: string, to: string): boolean {
|
|
423
|
-
const outgoing = new Map<string, string[]>()
|
|
424
|
-
for (const edge of graph.edges) {
|
|
425
|
-
if (!outgoing.has(edge.source)) outgoing.set(edge.source, [])
|
|
426
|
-
outgoing.get(edge.source)!.push(edge.target)
|
|
427
|
-
}
|
|
428
|
-
const seen = new Set<string>()
|
|
429
|
-
const stack = [from]
|
|
430
|
-
while (stack.length > 0) {
|
|
431
|
-
const id = stack.pop()!
|
|
432
|
-
if (id === to) return true
|
|
433
|
-
if (seen.has(id)) continue
|
|
434
|
-
seen.add(id)
|
|
435
|
-
for (const next of outgoing.get(id) ?? []) stack.push(next)
|
|
436
|
-
}
|
|
437
|
-
return false
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// The sym metric: largest cross-axis offset between a PORT_EXACT peer hub and
|
|
441
|
-
// its peer barycenter — directly the fan-in/fan-out symmetry the centering
|
|
442
|
-
// optimizes. LABELED spokes are INCLUDED (a mixed-label fan-in is exactly the
|
|
443
|
-
// case the co-rank default squares up; excluding its labeled spoke hid the win,
|
|
444
|
-
// so the metric measured nothing there). The peer-layer test is relaxed to the
|
|
445
|
-
// SAME 28px tolerance centerPeerBarycenters uses to decide a fan-in's sources
|
|
446
|
-
// are same-rank peers, so the metric and the centering pass agree on which
|
|
447
|
-
// fan-ins count: a co-ranked mixed-label fan-in is now seen and reads ≈0 when
|
|
448
|
-
// the hub is centered, instead of being invisible. The shape gate MATCHES the
|
|
449
|
-
// relaxed centerPeerBarycenters guard: the HUB may be any PORT_EXACT shape (a
|
|
450
|
-
// DECISION diamond, round, stadium, …) and so may a FAN-IN peer, so a diamond
|
|
451
|
-
// hub/source centering now registers instead of being invisible; FAN-OUT peers
|
|
452
|
-
// stay rectangle-only (that side is owned by applySymmetricFanoutEmissions).
|
|
453
|
-
// (Still requires distinct, mutually-unreachable peers on one flow layer.)
|
|
454
|
-
const SYM_PEER_LAYER_TOL = 28
|
|
455
|
-
function peerBarycenterDelta(positioned: PositionedGraph, graph: MermaidGraph): number {
|
|
456
|
-
const nodeMap = new Map(positioned.nodes.map(n => [n.id, n]))
|
|
457
|
-
const bySource = new Map<string, PositionedNode[]>()
|
|
458
|
-
const byTarget = new Map<string, PositionedNode[]>()
|
|
459
|
-
for (const edge of positioned.edges) {
|
|
460
|
-
const source = nodeMap.get(edge.source)
|
|
461
|
-
const target = nodeMap.get(edge.target)
|
|
462
|
-
if (!source || !target || !forwardish(source, target, graph.direction)) continue
|
|
463
|
-
if (!bySource.has(edge.source)) bySource.set(edge.source, [])
|
|
464
|
-
bySource.get(edge.source)!.push(target)
|
|
465
|
-
if (!byTarget.has(edge.target)) byTarget.set(edge.target, [])
|
|
466
|
-
byTarget.get(edge.target)!.push(source)
|
|
467
|
-
}
|
|
468
|
-
let worst = 0
|
|
469
|
-
// Peer-shape gate mirrors centerPeerBarycenters exactly so the metric measures
|
|
470
|
-
// precisely what the pass optimizes: FAN-IN peers may be any PORT_EXACT shape,
|
|
471
|
-
// FAN-OUT peers stay rectangle-only (applySymmetricFanoutEmissions owns non-rect
|
|
472
|
-
// fan-out spread; the metric must not claim a fan-out the pass never centres).
|
|
473
|
-
const update = (hub: PositionedNode | undefined, peers: PositionedNode[], side: 'in' | 'out') => {
|
|
474
|
-
if (!hub || peers.length < 2 || peers.length > 6 || !samePeerLayer(peers, graph.direction, SYM_PEER_LAYER_TOL)) return
|
|
475
|
-
if (!PORT_EXACT.has(hub.shape) || nodeInsideGroups(hub, positioned.groups)) return
|
|
476
|
-
const peerShapeOk = (peer: PositionedNode) => side === 'in' ? PORT_EXACT.has(peer.shape) : peer.shape === 'rectangle'
|
|
477
|
-
if (!peers.every(peer => peerShapeOk(peer) && !nodeInsideGroups(peer, positioned.groups))) return
|
|
478
|
-
if (new Set(peers.map(peer => peer.id)).size !== peers.length) return
|
|
479
|
-
for (let i = 0; i < peers.length; i++) for (let j = 0; j < peers.length; j++) {
|
|
480
|
-
if (i !== j && logicalGraphReaches(graph, peers[i]!.id, peers[j]!.id)) return
|
|
481
|
-
}
|
|
482
|
-
const barycenter = peers.reduce((sum, peer) => sum + nodeCrossCenter(peer, graph.direction), 0) / peers.length
|
|
483
|
-
worst = Math.max(worst, Math.abs(nodeCrossCenter(hub, graph.direction) - barycenter))
|
|
484
|
-
}
|
|
485
|
-
for (const [source, targets] of bySource) update(nodeMap.get(source), targets, 'out')
|
|
486
|
-
for (const [target, sources] of byTarget) update(nodeMap.get(target), sources, 'in')
|
|
487
|
-
return worst
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Score a positioned layout against the rubric. Pure and deterministic:
|
|
492
|
-
* identical input produces identical metrics.
|
|
493
|
-
*/
|
|
494
|
-
export function assessLayout(graph: MermaidGraph, positioned: PositionedGraph): RubricResult {
|
|
495
|
-
const style = resolveRenderStyle({})
|
|
496
|
-
// Built without severity, then enriched + sorted by impact at the return.
|
|
497
|
-
const violations: Array<Omit<RubricViolation, 'severity'>> = []
|
|
498
|
-
const nodeMap = new Map(positioned.nodes.map(n => [n.id, n]))
|
|
499
|
-
|
|
500
|
-
let offOutline = 0
|
|
501
|
-
let diagonal = 0
|
|
502
|
-
let unexplainedBends = 0
|
|
503
|
-
let labelOffRoute = 0
|
|
504
|
-
let totalBends = 0
|
|
505
|
-
let maxBends = 0
|
|
506
|
-
let portEnds = 0
|
|
507
|
-
let portAnchoredEdges = 0
|
|
508
|
-
let measuredEnds = 0
|
|
509
|
-
let worstLabelOffset = 0
|
|
510
|
-
|
|
511
|
-
for (const e of positioned.edges) {
|
|
512
|
-
const id = `${e.source}->${e.target}`
|
|
513
|
-
const bends = Math.max(0, e.points.length - 2)
|
|
514
|
-
// Justified-bend exemption: a bend that is part of a SYMMETRIC convergence
|
|
515
|
-
// (a fan-out/fan-in bundle, or a co-ranked mixed-label fan-in's dogleg) is
|
|
516
|
-
// "as good as straight" — the bend is structurally necessary to converge and
|
|
517
|
-
// the symmetry it buys offsets the cost, the same idiom every reference
|
|
518
|
-
// layered drawer (and our own fan-out emitter) uses. So it does not count
|
|
519
|
-
// toward totalBends/maxBendsPerEdge. We key off the SAME 'bundle' certificate
|
|
520
|
-
// findRouteHitches uses to skip these edges (route-contracts.ts), so the bend
|
|
521
|
-
// penalty and the HARD hitch-invariant AGREE on what is justified. This does
|
|
522
|
-
// NOT touch unexplainedBends (which fires only on 'straight'-certified edges,
|
|
523
|
-
// and a bundle edge is never 'straight') or hitches (HARD, unchanged).
|
|
524
|
-
const justifiedConvergenceBend = e.routeCertificate?.invariant === 'bundle'
|
|
525
|
-
if (!justifiedConvergenceBend) {
|
|
526
|
-
totalBends += bends
|
|
527
|
-
maxBends = Math.max(maxBends, bends)
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (bends > 0 && e.routeCertificate?.invariant === 'straight') {
|
|
531
|
-
unexplainedBends += bends
|
|
532
|
-
violations.push({ metric: 'unexplainedBends', detail: `${id} certified straight with ${bends} bends` })
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
for (let i = 1; i < e.points.length; i++) {
|
|
536
|
-
const a = e.points[i - 1]!, b = e.points[i]!
|
|
537
|
-
if (Math.abs(a.x - b.x) > 0.01 && Math.abs(a.y - b.y) > 0.01) {
|
|
538
|
-
diagonal++
|
|
539
|
-
violations.push({ metric: 'diagonalSegments', detail: `${id} segment ${i}` })
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
for (const [nodeId, pt, label] of [
|
|
544
|
-
[e.source, e.points[0]!, 'source'],
|
|
545
|
-
[e.target, e.points[e.points.length - 1]!, 'target'],
|
|
546
|
-
] as const) {
|
|
547
|
-
const node = nodeMap.get(nodeId)
|
|
548
|
-
if (!node) continue
|
|
549
|
-
measuredEnds++
|
|
550
|
-
if (!onShapeOutline(node, pt)) {
|
|
551
|
-
offOutline++
|
|
552
|
-
violations.push({ metric: 'offOutlineEndpoints', detail: `${id} ${label} on ${nodeId}(${node.shape}) at (${pt.x.toFixed(1)},${pt.y.toFixed(1)})` })
|
|
553
|
-
}
|
|
554
|
-
if (onDesignatedPort(node, pt)) portEnds++
|
|
555
|
-
}
|
|
556
|
-
const srcNode = nodeMap.get(e.source)
|
|
557
|
-
const tgtNode = nodeMap.get(e.target)
|
|
558
|
-
const onPort = (node: PositionedNode | undefined, pt: Point) => node ? onDesignatedPort(node, pt) : false
|
|
559
|
-
if (onPort(srcNode, e.points[0]!) || onPort(tgtNode, e.points[e.points.length - 1]!)) portAnchoredEdges++
|
|
560
|
-
|
|
561
|
-
if (e.label && e.labelPosition && e.points.length >= 2) {
|
|
562
|
-
const m = measureMultilineText(e.label, style.edgeLabelFontSize, style.edgeLabelFontWeight)
|
|
563
|
-
const allow = (m.height + 16) / 2 + 4
|
|
564
|
-
// A self-loop's label legitimately sits BESIDE the small loop stub (ELK's
|
|
565
|
-
// native placement), so its CENTRE is a pill-width from the stub while its
|
|
566
|
-
// body touches it. Measure pill-to-route for self-loops; centre-to-route
|
|
567
|
-
// for ordinary edges, where the label is meant to sit ON the line.
|
|
568
|
-
const selfLoop = e.source === e.target
|
|
569
|
-
let best = Infinity
|
|
570
|
-
for (let i = 1; i < e.points.length; i++) {
|
|
571
|
-
best = Math.min(best, selfLoop
|
|
572
|
-
? rectToSegmentDistance(e.labelPosition.x, e.labelPosition.y, (m.width + 16) / 2, (m.height + 16) / 2, e.points[i - 1]!, e.points[i]!)
|
|
573
|
-
: pointToSegmentDistance(e.labelPosition, e.points[i - 1]!, e.points[i]!))
|
|
574
|
-
}
|
|
575
|
-
if (best > allow) {
|
|
576
|
-
labelOffRoute++
|
|
577
|
-
violations.push({ metric: 'labelOffRoute', detail: `${id} label ${best.toFixed(1)}px from its route (allowed ${allow.toFixed(1)})` })
|
|
578
|
-
}
|
|
579
|
-
// SOFT label-CENTRING aggregate: track the worst (most off-centre) label.
|
|
580
|
-
worstLabelOffset = Math.max(worstLabelOffset, labelMidpointOffset(e))
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
let edgeThroughNode = 0
|
|
585
|
-
for (const e of positioned.edges) {
|
|
586
|
-
for (const node of positioned.nodes) {
|
|
587
|
-
if (node.id === e.source || node.id === e.target) continue
|
|
588
|
-
for (let i = 1; i < e.points.length; i++) {
|
|
589
|
-
if (segmentThroughShape(e.points[i - 1]!, e.points[i]!, node)) {
|
|
590
|
-
edgeThroughNode++
|
|
591
|
-
violations.push({ metric: 'edgeThroughNode', detail: `${e.source}->${e.target} segment ${i} through ${node.id}` })
|
|
592
|
-
break
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
let crossings = 0
|
|
599
|
-
for (let i = 0; i < positioned.edges.length; i++) {
|
|
600
|
-
for (let j = i + 1; j < positioned.edges.length; j++) {
|
|
601
|
-
const e1 = positioned.edges[i]!, e2 = positioned.edges[j]!
|
|
602
|
-
for (let s = 1; s < e1.points.length; s++) {
|
|
603
|
-
for (let q = 1; q < e2.points.length; q++) {
|
|
604
|
-
if (segmentsIntersect(e1.points[s - 1]!, e1.points[s]!, e2.points[q - 1]!, e2.points[q]!)) crossings++
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
let nodeOverlaps = 0
|
|
611
|
-
for (let i = 0; i < positioned.nodes.length; i++) {
|
|
612
|
-
for (let j = i + 1; j < positioned.nodes.length; j++) {
|
|
613
|
-
const a = positioned.nodes[i]!, b = positioned.nodes[j]!
|
|
614
|
-
const ox = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)
|
|
615
|
-
const oy = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)
|
|
616
|
-
if (ox > 0.5 && oy > 0.5) {
|
|
617
|
-
nodeOverlaps++
|
|
618
|
-
violations.push({ metric: 'nodeOverlaps', detail: `${a.id} overlaps ${b.id}` })
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const hitchList = findRouteHitches(positioned, graph)
|
|
624
|
-
for (const h of hitchList) violations.push({ metric: 'hitches', detail: `${h.edge} deviates ${h.deviationPx}px with a clear lane` })
|
|
625
|
-
|
|
626
|
-
return {
|
|
627
|
-
metrics: {
|
|
628
|
-
edges: positioned.edges.length,
|
|
629
|
-
offOutlineEndpoints: offOutline,
|
|
630
|
-
diagonalSegments: diagonal,
|
|
631
|
-
unexplainedBends,
|
|
632
|
-
hitches: hitchList.length,
|
|
633
|
-
nodeOverlaps,
|
|
634
|
-
labelOffRoute,
|
|
635
|
-
edgeThroughNode,
|
|
636
|
-
edgeCrossings: crossings,
|
|
637
|
-
totalBends,
|
|
638
|
-
maxBendsPerEdge: maxBends,
|
|
639
|
-
portEndpointRate: measuredEnds === 0 ? 1 : portEnds / measuredEnds,
|
|
640
|
-
portAnchoredEdgeRate: positioned.edges.length === 0 ? 1 : portAnchoredEdges / positioned.edges.length,
|
|
641
|
-
peerBarycenterDelta: peerBarycenterDelta(positioned, graph),
|
|
642
|
-
worstLabelOffset,
|
|
643
|
-
},
|
|
644
|
-
violations: violations
|
|
645
|
-
.map(v => ({ ...v, severity: rubricSeverity(v.metric) }))
|
|
646
|
-
.sort((a, b) => RUBRIC_SEVERITY_ORDER[a.severity] - RUBRIC_SEVERITY_ORDER[b.severity]),
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/** The hard rubric metrics — must be zero for every diagram, always. */
|
|
651
|
-
export const HARD_METRICS = [
|
|
652
|
-
'offOutlineEndpoints', 'diagonalSegments', 'unexplainedBends',
|
|
653
|
-
'hitches', 'nodeOverlaps', 'labelOffRoute', 'edgeThroughNode',
|
|
654
|
-
] as const
|
|
655
|
-
|
|
656
|
-
export function hardViolations(result: RubricResult): RubricViolation[] {
|
|
657
|
-
const hard = new Set<string>(HARD_METRICS)
|
|
658
|
-
return result.violations.filter(v => hard.has(v.metric))
|
|
659
|
-
}
|