agentic-mermaid 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_NATIVE.md +155 -91
- package/CHANGELOG.md +163 -4
- package/Instructions_for_agents.md +10 -10
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +31 -38
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/assets/hero.png +0 -0
- package/dist/agent-core.d.ts +463 -0
- package/dist/agent-core.js +397 -0
- package/dist/agent.d.ts +73 -1868
- package/dist/agent.js +336 -37325
- package/dist/agentic-mermaid-mcp.js +7 -38399
- package/dist/am.js +33626 -40446
- package/dist/chunk-2P5FKUQC.js +3485 -0
- package/dist/chunk-2S3VSS6I.js +149 -0
- package/dist/chunk-2SILJDW2.js +37 -0
- package/dist/chunk-BJYMBT4I.js +61963 -0
- package/dist/chunk-FDM43XU7.js +615 -0
- package/dist/chunk-MYPDVZCO.js +4702 -0
- package/dist/index-DWPa2MJs.d.ts +6780 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +310 -33014
- package/dist/mcp-cli-5J47WEY3.js +17 -0
- package/docs/README.md +39 -31
- package/docs/agent-api-cookbook.md +44 -35
- package/docs/agent-mutation-policy.md +1 -1
- package/docs/agent-workflow-examples.md +2 -2
- package/docs/api.md +284 -77
- package/docs/ascii.md +26 -16
- package/docs/code-mode-impact.md +214 -0
- package/docs/comparison.md +23 -11
- package/docs/computational-aesthetics-prototype-plan.md +252 -0
- package/docs/config.md +26 -10
- package/docs/custom-fonts.md +265 -0
- package/docs/custom-style-cookbook.md +130 -9
- package/docs/diagram-families.md +48 -23
- package/docs/features.md +59 -52
- package/docs/fork-differences.md +6 -3
- package/docs/getting-started.md +23 -16
- package/docs/issue-derived-test-cases.md +29 -22
- package/docs/mcp-code-mode-rationale.md +13 -6
- package/docs/mcp-http-transport.md +20 -13
- package/docs/mermaid-layout-complaints.md +66 -52
- package/docs/mutation-testing.md +145 -66
- package/docs/quality.md +23 -14
- package/docs/react.md +2 -2
- package/docs/schemas/style-spec.schema.json +1221 -30
- package/docs/style-authoring.md +241 -38
- package/docs/svg-semantic-contract.md +49 -0
- package/docs/testing-strategy.md +166 -58
- package/docs/theming.md +19 -32
- package/examples/styles/catalog.json +54 -0
- package/examples/styles/cloudflare-workers-inspired-prototype.mmd +9 -0
- package/examples/styles/cloudflare-workers-inspired-prototype.style.json +110 -0
- package/examples/styles/cupertino-prototype.mmd +10 -0
- package/examples/styles/cupertino-prototype.style.json +63 -0
- package/examples/styles/mid-century-report.style.json +2 -2
- package/examples/styles/star-chart-atlas.style.json +2 -1
- package/examples/styles/transit-route-map.style.json +2 -2
- package/examples/styles/vercel-inspired-prototype.mmd +6 -0
- package/examples/styles/vercel-inspired-prototype.style.json +87 -0
- package/package.json +85 -45
- package/server.json +2 -2
- package/skills/agentic-mermaid-diagram-workflow/SKILL.md +17 -36
- package/skills/agentic-mermaid-diagram-workflow/references/cli.md +4 -5
- package/skills/agentic-mermaid-diagram-workflow/references/code-mode.md +15 -31
- package/skills/agentic-mermaid-diagram-workflow/references/sequence.md +10 -14
- package/skills/agentic-mermaid-live-editor/SKILL.md +100 -206
- package/TODO.md +0 -684
- package/dist/index-wjRjbsP-.d.ts +0 -1102
- package/docs/assets/improvements/box-start-connector.png +0 -0
- package/docs/assets/improvements/comment-dropped-lint.png +0 -0
- package/docs/assets/improvements/er-content-loss.png +0 -0
- package/docs/assets/improvements/fan-in-trunks.png +0 -0
- package/docs/assets/improvements/gantt-family.png +0 -0
- package/docs/assets/improvements/gantt-milestone-clamp.png +0 -0
- package/docs/assets/improvements/gantt-verify-seam.png +0 -0
- package/docs/assets/improvements/subgraph-container-edges.png +0 -0
- package/docs/assets/improvements/wrapper-frontmatter.png +0 -0
- package/docs/assets/improvements/wrapper-init-directive.png +0 -0
- package/docs/assets/improvements/wrapper-leading-comment.png +0 -0
- package/docs/assets/style-cookbook/mid-century-report.png +0 -0
- package/docs/assets/style-cookbook/star-chart-atlas.png +0 -0
- package/docs/assets/style-cookbook/transit-route-map.png +0 -0
- package/docs/contributing/adding-diagram-types.md +0 -144
- package/docs/contributing/diagram-family-citizenship.matrix.json +0 -2127
- package/docs/contributing/diagram-family-citizenship.md +0 -82
- package/docs/contributing/harvesting-upstream-tests.md +0 -126
- package/docs/contributing/lessons-learned.md +0 -276
- package/docs/contributing/releasing.md +0 -82
- package/docs/contributing/visual-review-evidence.md +0 -53
- package/docs/design/families/README.md +0 -26
- package/docs/design/families/architecture-beta.md +0 -48
- package/docs/design/families/flowchart-parser-conformance.md +0 -22
- package/docs/design/families/gantt-research.md +0 -137
- package/docs/design/families/gantt.md +0 -329
- package/docs/design/families/journey-agentic-cards-before.png +0 -0
- package/docs/design/families/journey-experience-curve-implementation.png +0 -0
- package/docs/design/families/journey-mermaid-classic-implementation.png +0 -0
- package/docs/design/families/journey-mermaid-classic-prototype.png +0 -0
- package/docs/design/families/journey-mermaid-classic-prototype.svg +0 -163
- package/docs/design/families/journey-mermaid-classic-prototype.svg.png +0 -0
- package/docs/design/families/journey-migration-parity.md +0 -439
- package/docs/design/families/journey-section-overlap-after.png +0 -0
- package/docs/design/families/journey-section-overlap-before.png +0 -0
- package/docs/design/families/journey-usage-research.md +0 -423
- package/docs/design/families/journey.md +0 -153
- package/docs/design/families/xychart.md +0 -85
- package/docs/design/system/README.md +0 -45
- package/docs/design/system/abstraction-audit.md +0 -224
- package/docs/design/system/abstraction-recommendations.md +0 -655
- package/docs/design/system/architecture.mmd +0 -29
- package/docs/design/system/architecture.svg +0 -137
- package/docs/design/system/layout-guarantees-and-robustness.md +0 -351
- package/docs/design/system/layout-rubric.md +0 -138
- package/docs/design/system/route-contracts.md +0 -848
- package/docs/design/system/source-preservation-ladder.md +0 -52
- package/docs/design/system/styles-rollout.md +0 -216
- package/docs/design/system/ugly-layouts.md +0 -119
- package/docs/layout-characterization/README.md +0 -245
- package/docs/layout-characterization/contact-sheet-families.md +0 -498
- package/docs/layout-characterization/contact-sheet.md +0 -638
- package/docs/layout-characterization/properties.md +0 -281
- package/docs/layout-characterization/visual-quality.md +0 -160
- package/docs/layout-characterization/visual-snapshots/architecture.svg +0 -79
- package/docs/layout-characterization/visual-snapshots/class.svg +0 -60
- package/docs/layout-characterization/visual-snapshots/er.svg +0 -61
- package/docs/layout-characterization/visual-snapshots/flowchart.svg +0 -58
- package/docs/layout-characterization/visual-snapshots/gantt.svg +0 -52
- package/docs/layout-characterization/visual-snapshots/journey.svg +0 -98
- package/docs/layout-characterization/visual-snapshots/pie.svg +0 -36
- package/docs/layout-characterization/visual-snapshots/quadrant.svg +0 -47
- package/docs/layout-characterization/visual-snapshots/sequence.svg +0 -59
- package/docs/layout-characterization/visual-snapshots/state.svg +0 -52
- package/docs/layout-characterization/visual-snapshots/timeline.svg +0 -64
- package/docs/layout-characterization/visual-snapshots/xychart.svg +0 -97
- package/docs/project/divergences.md +0 -98
- package/docs/project/dogfooding-docs-strategy.md +0 -66
- package/docs/project/hosted-mcp-cloudflare-plan.md +0 -338
- package/docs/project/lessons-learned.md +0 -1019
- package/docs/project/remove-role-styling-plan.md +0 -176
- package/examples/agent-improve-auth-flow.ts +0 -206
- package/examples/agent-loop.ts +0 -53
- package/examples/mcp-vs-cli-complex-diagrams.ts +0 -133
- package/llms.txt +0 -104
- package/skill-evals/README.md +0 -77
- package/skill-evals/fixtures/architecture-cache/input.mmd +0 -5
- package/skill-evals/fixtures/class-payment/input.mmd +0 -4
- package/skill-evals/fixtures/er-orders/input.mmd +0 -10
- package/skill-evals/fixtures/flowchart-cache/input.mmd +0 -3
- package/skill-evals/fixtures/gantt-release/input.mmd +0 -7
- package/skill-evals/fixtures/journey-checkout/input.mmd +0 -6
- package/skill-evals/fixtures/pie-exports/input.mmd +0 -5
- package/skill-evals/fixtures/quadrant-priorities/input.mmd +0 -11
- package/skill-evals/fixtures/sequence-retry/input.mmd +0 -8
- package/skill-evals/fixtures/state-lifecycle/input.mmd +0 -5
- package/skill-evals/fixtures/timeline-release/input.mmd +0 -6
- package/skill-evals/fixtures/xychart-growth/input.mmd +0 -5
- package/skill-evals/shared-benchmark.json +0 -510
- package/src/agent/analyze.ts +0 -157
- package/src/agent/apply.ts +0 -146
- package/src/agent/architecture-body.ts +0 -458
- package/src/agent/certificates.ts +0 -110
- package/src/agent/class-body.ts +0 -319
- package/src/agent/core.ts +0 -207
- package/src/agent/create.ts +0 -106
- package/src/agent/describe.ts +0 -368
- package/src/agent/er-body.ts +0 -224
- package/src/agent/facts.ts +0 -360
- package/src/agent/families-builtin.ts +0 -920
- package/src/agent/families.ts +0 -232
- package/src/agent/family-layouts.ts +0 -832
- package/src/agent/flowchart-body.ts +0 -372
- package/src/agent/flowchart-unsupported.ts +0 -243
- package/src/agent/font-coverage.ts +0 -288
- package/src/agent/gantt-body.ts +0 -495
- package/src/agent/index.ts +0 -10
- package/src/agent/journey-body.ts +0 -374
- package/src/agent/label-metrics.ts +0 -28
- package/src/agent/layout-to-rendered.ts +0 -63
- package/src/agent/mutate.ts +0 -97
- package/src/agent/mutation-ops.ts +0 -51
- package/src/agent/op-schema.ts +0 -443
- package/src/agent/parse.ts +0 -223
- package/src/agent/pie-body.ts +0 -270
- package/src/agent/png.ts +0 -171
- package/src/agent/quadrant-body.ts +0 -336
- package/src/agent/quality.ts +0 -363
- package/src/agent/readability-audit.ts +0 -92
- package/src/agent/rendered-route-audit.ts +0 -61
- package/src/agent/sequence-body.ts +0 -345
- package/src/agent/serialize.ts +0 -214
- package/src/agent/state-body.ts +0 -589
- package/src/agent/structural-count.ts +0 -150
- package/src/agent/timeline-body.ts +0 -280
- package/src/agent/trace-log.ts +0 -43
- package/src/agent/types.ts +0 -995
- package/src/agent/verify.ts +0 -603
- package/src/agent/xychart-body.ts +0 -472
- package/src/architecture/config.ts +0 -282
- package/src/architecture/layout.ts +0 -496
- package/src/architecture/parser.ts +0 -375
- package/src/architecture/renderer.ts +0 -611
- package/src/architecture/types.ts +0 -113
- package/src/ascii/ansi.ts +0 -423
- package/src/ascii/architecture.ts +0 -210
- package/src/ascii/canvas.ts +0 -443
- package/src/ascii/class-diagram.ts +0 -697
- package/src/ascii/converter.ts +0 -475
- package/src/ascii/draw.ts +0 -1693
- package/src/ascii/edge-bundling.ts +0 -328
- package/src/ascii/edge-routing.ts +0 -330
- package/src/ascii/er-diagram.ts +0 -439
- package/src/ascii/gantt.ts +0 -276
- package/src/ascii/grid.ts +0 -747
- package/src/ascii/index.ts +0 -196
- package/src/ascii/journey.ts +0 -169
- package/src/ascii/meta.ts +0 -244
- package/src/ascii/multiline-utils.ts +0 -80
- package/src/ascii/pathfinder.ts +0 -301
- package/src/ascii/pie.ts +0 -67
- package/src/ascii/quadrant.ts +0 -158
- package/src/ascii/reverse.ts +0 -216
- package/src/ascii/sequence.ts +0 -499
- package/src/ascii/shapes/circle.ts +0 -27
- package/src/ascii/shapes/corners.ts +0 -143
- package/src/ascii/shapes/diamond.ts +0 -27
- package/src/ascii/shapes/hexagon.ts +0 -27
- package/src/ascii/shapes/index.ts +0 -106
- package/src/ascii/shapes/rectangle.ts +0 -168
- package/src/ascii/shapes/rounded.ts +0 -27
- package/src/ascii/shapes/special.ts +0 -332
- package/src/ascii/shapes/stadium.ts +0 -110
- package/src/ascii/shapes/state.ts +0 -192
- package/src/ascii/shapes/types.ts +0 -73
- package/src/ascii/timeline.ts +0 -141
- package/src/ascii/types.ts +0 -312
- package/src/ascii/validate.ts +0 -120
- package/src/ascii/width.ts +0 -81
- package/src/ascii/wrap.ts +0 -131
- package/src/ascii/xychart.ts +0 -919
- package/src/browser.ts +0 -29
- package/src/class/layout.ts +0 -240
- package/src/class/parser.ts +0 -332
- package/src/class/renderer.ts +0 -638
- package/src/class/types.ts +0 -129
- package/src/cli/agent-instructions.ts +0 -1
- package/src/cli/am-bin.ts +0 -6
- package/src/cli/exit-codes.ts +0 -18
- package/src/cli/index.ts +0 -1132
- package/src/cli/init-agent.ts +0 -147
- package/src/cli/run-entrypoint.ts +0 -10
- package/src/color-resolver.ts +0 -152
- package/src/elk-instance.ts +0 -114
- package/src/er/layout.ts +0 -187
- package/src/er/parser.ts +0 -226
- package/src/er/renderer.ts +0 -792
- package/src/er/types.ts +0 -99
- package/src/family-rubric.ts +0 -425
- package/src/font-manifest.ts +0 -31
- package/src/gantt/layout.ts +0 -365
- package/src/gantt/parser.ts +0 -282
- package/src/gantt/pipeline.ts +0 -27
- package/src/gantt/renderer.ts +0 -414
- package/src/gantt/schedule.ts +0 -513
- package/src/gantt/types.ts +0 -255
- package/src/index.ts +0 -368
- package/src/journey/layout.ts +0 -554
- package/src/journey/parse-core.ts +0 -251
- package/src/journey/parser.ts +0 -101
- package/src/journey/renderer.ts +0 -917
- package/src/journey/types.ts +0 -144
- package/src/layout/geometry.ts +0 -182
- package/src/layout/pass.ts +0 -142
- package/src/layout/passes/index.ts +0 -3916
- package/src/layout-engine.ts +0 -1565
- package/src/layout-rubric.ts +0 -659
- package/src/mcp/artifacts.ts +0 -137
- package/src/mcp/dynamic-harness.ts +0 -31
- package/src/mcp/dynamic-modules.d.ts +0 -7
- package/src/mcp/facade.ts +0 -608
- package/src/mcp/harness-runtime.ts +0 -203
- package/src/mcp/hosted-server.ts +0 -468
- package/src/mcp/mcp-bin.ts +0 -3
- package/src/mcp/mcp-cli.ts +0 -82
- package/src/mcp/protocol.ts +0 -13
- package/src/mcp/sandbox.ts +0 -195
- package/src/mcp/sdk-decl.ts +0 -391
- package/src/mcp/server.ts +0 -362
- package/src/mcp/tool-surface.ts +0 -146
- package/src/mermaid-source.ts +0 -655
- package/src/multiline-utils.ts +0 -228
- package/src/parser.ts +0 -1031
- package/src/pie/layout.ts +0 -168
- package/src/pie/parser.ts +0 -136
- package/src/pie/renderer.ts +0 -234
- package/src/pie/types.ts +0 -82
- package/src/quadrant/layout.ts +0 -191
- package/src/quadrant/parser.ts +0 -183
- package/src/quadrant/renderer.ts +0 -322
- package/src/quadrant/types.ts +0 -113
- package/src/render-family-hooks.ts +0 -244
- package/src/renderer.ts +0 -1099
- package/src/route-contracts.ts +0 -2723
- package/src/scene/backend.ts +0 -76
- package/src/scene/fidelity.ts +0 -183
- package/src/scene/hybrid-backend.ts +0 -174
- package/src/scene/ir.ts +0 -181
- package/src/scene/marks.ts +0 -127
- package/src/scene/rough-backend.ts +0 -378
- package/src/scene/seed.ts +0 -46
- package/src/scene/style-registry.ts +0 -556
- package/src/sequence/layout.ts +0 -456
- package/src/sequence/parser.ts +0 -255
- package/src/sequence/renderer.ts +0 -604
- package/src/sequence/types.ts +0 -154
- package/src/shape-clipping.ts +0 -351
- package/src/shared/accessibility-directives.ts +0 -22
- package/src/shared/batched.ts +0 -61
- package/src/shared/color-math.ts +0 -93
- package/src/shared/svg-a11y.ts +0 -29
- package/src/shared/syntax-error.ts +0 -17
- package/src/shared/unicode-ranges.ts +0 -135
- package/src/styles.ts +0 -318
- package/src/svg-paths.ts +0 -28
- package/src/text-metrics.ts +0 -248
- package/src/theme.ts +0 -710
- package/src/timeline/layout.ts +0 -381
- package/src/timeline/parser.ts +0 -215
- package/src/timeline/renderer.ts +0 -538
- package/src/timeline/types.ts +0 -105
- package/src/types.ts +0 -462
- package/src/website-worker-modules.d.ts +0 -18
- package/src/xychart/axis-utils.ts +0 -109
- package/src/xychart/colors.ts +0 -124
- package/src/xychart/config.ts +0 -88
- package/src/xychart/layout.ts +0 -656
- package/src/xychart/parser.ts +0 -504
- package/src/xychart/renderer.ts +0 -640
- package/src/xychart/types.ts +0 -282
package/src/agent/quality.ts
DELETED
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
// ============================================================================
|
|
2
|
-
// Perceptual quality metrics for rendered diagrams.
|
|
3
|
-
//
|
|
4
|
-
// Deterministic, layout-derived metrics — meant for CI gating. The numeric
|
|
5
|
-
// results are stable across runs because they depend only on the ELK layout
|
|
6
|
-
// (which is itself deterministic, see AGENT_NATIVE.md § determinism).
|
|
7
|
-
//
|
|
8
|
-
// What's measured:
|
|
9
|
-
// - edgeCrossings: number of pairwise edge-segment intersections (excluding
|
|
10
|
-
// shared endpoints). Lower is better.
|
|
11
|
-
// - labelLegibility: fraction of node labels whose rendered length fits the
|
|
12
|
-
// node's width at a 12px font baseline.
|
|
13
|
-
// - whitespaceBalance: ratio of node-occupied area to total canvas area
|
|
14
|
-
// (0..1). Targets a band — too dense AND too sparse both lose points.
|
|
15
|
-
// - labelEdgeProximity: min pixel distance between any edge-label bbox and
|
|
16
|
-
// the nearest non-attached node bbox, other edge-label bbox, or non-own
|
|
17
|
-
// edge path. Lower means labels overlap nodes, edges, or each other.
|
|
18
|
-
// - aspectRatio: canvas width/height. Used as a sanity check, not a gate.
|
|
19
|
-
// ============================================================================
|
|
20
|
-
|
|
21
|
-
import type { RenderedLayout, RenderedLayoutNode, RenderedLayoutEdge } from './types.ts'
|
|
22
|
-
import { measureMultilineText } from '../text-metrics.ts'
|
|
23
|
-
import { FONT_SIZES, FONT_WEIGHTS } from '../styles.ts'
|
|
24
|
-
|
|
25
|
-
export interface QualityMetrics {
|
|
26
|
-
edgeCrossings: number
|
|
27
|
-
labelLegibility: number // 0..1 — fraction of labels that fit
|
|
28
|
-
whitespaceBalance: number // 0..1 — fill ratio
|
|
29
|
-
labelEdgeProximity: number // pixels; Infinity if no edge labels
|
|
30
|
-
aspectRatio: number // w/h; Infinity if h=0
|
|
31
|
-
nodeCount: number
|
|
32
|
-
edgeCount: number
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface QualityBounds {
|
|
36
|
-
/** Max edge crossings per (edge × edge) pair. Default 0.05 = 5%. */
|
|
37
|
-
maxCrossingsRatio?: number
|
|
38
|
-
/** Min label legibility. Default 0.85 = 85% of labels fit. */
|
|
39
|
-
minLabelLegibility?: number
|
|
40
|
-
/** Whitespace fill band [min, max]. Default [0.05, 0.55]. */
|
|
41
|
-
whitespaceBand?: [number, number]
|
|
42
|
-
/** Min pixels between any edge-label bbox and a non-attached node, another edge-label, or another edge path. Default 4. */
|
|
43
|
-
minLabelEdgeProximity?: number
|
|
44
|
-
/** Aspect ratio band [min, max]. Default [0.2, 5.0]. */
|
|
45
|
-
aspectBand?: [number, number]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const DEFAULT_BOUNDS: Required<QualityBounds> = {
|
|
49
|
-
maxCrossingsRatio: 0.05,
|
|
50
|
-
minLabelLegibility: 0.85,
|
|
51
|
-
whitespaceBand: [0.05, 0.55],
|
|
52
|
-
minLabelEdgeProximity: 4,
|
|
53
|
-
aspectBand: [0.2, 5.0],
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---- provenance + severity -------------------------------------------------
|
|
57
|
-
//
|
|
58
|
-
// The bands above are a mix of evidence-backed and chosen thresholds, and
|
|
59
|
-
// pretending they are equally well-founded is dishonest (quality.md: "we do
|
|
60
|
-
// not claim our metrics match a human designer's eye"). The graph-drawing
|
|
61
|
-
// aesthetics literature — Purchase, "Which Aesthetic Has the Greatest Effect
|
|
62
|
-
// on Human Understanding?" (GD 1997) and "Metrics for Graph Drawing
|
|
63
|
-
// Aesthetics" (JVLC 2002) — measured, with human subjects, that minimizing
|
|
64
|
-
// EDGE CROSSINGS dominates comprehension; bends and symmetry matter far less;
|
|
65
|
-
// and aspect ratio is a sanity bound, not an aesthetic. We record that
|
|
66
|
-
// provenance here so a reader knows which violation to weight, and so future
|
|
67
|
-
// recalibration starts from the evidence rather than from feel.
|
|
68
|
-
|
|
69
|
-
export type BoundBasis =
|
|
70
|
-
| 'evidence' // backed by human-subject graph-drawing studies
|
|
71
|
-
| 'derived' // mechanical correctness (labels must physically fit)
|
|
72
|
-
| 'chosen' // plausible heuristic, not validated against human perception
|
|
73
|
-
| 'sanity' // a guardrail against the absurd, not an aesthetic target
|
|
74
|
-
|
|
75
|
-
export type ViolationSeverity =
|
|
76
|
-
| 'primary' // the aesthetic with the strongest evidence of impact
|
|
77
|
-
| 'secondary' // measurable but weaker human-comprehension impact
|
|
78
|
-
| 'sanity' // out-of-range guardrail, not a quality verdict
|
|
79
|
-
|
|
80
|
-
export interface BoundProvenance {
|
|
81
|
-
basis: BoundBasis
|
|
82
|
-
severity: ViolationSeverity
|
|
83
|
-
note: string
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Per-metric provenance. Keyed by the QualityMetrics field each bound gates. */
|
|
87
|
-
export const BOUND_PROVENANCE: Record<
|
|
88
|
-
'edgeCrossings' | 'labelLegibility' | 'whitespaceBalance' | 'labelEdgeProximity' | 'aspectRatio',
|
|
89
|
-
BoundProvenance
|
|
90
|
-
> = {
|
|
91
|
-
edgeCrossings: {
|
|
92
|
-
basis: 'evidence', severity: 'primary',
|
|
93
|
-
note: 'Crossings are the single aesthetic with the strongest human-subject evidence (Purchase 1997/2002). Weight this violation highest.',
|
|
94
|
-
},
|
|
95
|
-
labelLegibility: {
|
|
96
|
-
basis: 'derived', severity: 'secondary',
|
|
97
|
-
note: 'Labels that exceed node width are mechanically unreadable; the 7px/char model under-estimates fit under condensed fonts.',
|
|
98
|
-
},
|
|
99
|
-
labelEdgeProximity: {
|
|
100
|
-
basis: 'chosen', severity: 'secondary',
|
|
101
|
-
note: 'Edge-labels overlapping nodes hurt readability, but the 4px floor is chosen, not validated against perception.',
|
|
102
|
-
},
|
|
103
|
-
whitespaceBalance: {
|
|
104
|
-
basis: 'chosen', severity: 'secondary',
|
|
105
|
-
note: 'A node/canvas fill band is a rough proxy for "too sparse / too dense"; the literature gives weak support and the band is hand-set.',
|
|
106
|
-
},
|
|
107
|
-
aspectRatio: {
|
|
108
|
-
basis: 'sanity', severity: 'sanity',
|
|
109
|
-
note: 'Aspect ratio is a guardrail against absurd canvases, not an aesthetic; Purchase did not find it drives comprehension.',
|
|
110
|
-
},
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// JUSTIFIED / SYMMETRIC BEND — a bend in the aesthetic hierarchy.
|
|
114
|
-
//
|
|
115
|
-
// Purchase found bends matter for comprehension, but not every bend is a defect.
|
|
116
|
-
// A bend that is part of a SYMMETRIC convergence (a fan-out/fan-in bundle, or a
|
|
117
|
-
// co-ranked mixed-label fan-in's converging dogleg) is "as good as" a straight
|
|
118
|
-
// line and must NOT be penalized: the bend is structurally necessary to converge
|
|
119
|
-
// (you cannot merge N spokes into one node without a turn), and the symmetry it
|
|
120
|
-
// buys — every reference layered drawer routes a fan this way, and our own
|
|
121
|
-
// fan-out emitter applySymmetricFanoutEmissions does too — offsets the small bend
|
|
122
|
-
// cost. Only UNJUSTIFIED / lone bends still cost. The geometric rubric encodes
|
|
123
|
-
// this exactly (src/layout-rubric.ts): bundle-certified bends are excluded from
|
|
124
|
-
// totalBends/maxBendsPerEdge — the SAME edges findRouteHitches treats as
|
|
125
|
-
// non-hitches — so the bend penalty and the HARD hitch-invariant agree on what
|
|
126
|
-
// counts as justified, while an off-lane jog with a clear straight lane (hitches)
|
|
127
|
-
// or a bend on a 'straight'-certified edge (unexplainedBends) stays a violation.
|
|
128
|
-
|
|
129
|
-
const CHAR_PX = 7 // approx character width at 12px font
|
|
130
|
-
const EDGE_LABEL_BOX_PADDING = 8
|
|
131
|
-
const GEOM_EPS = 1e-9
|
|
132
|
-
|
|
133
|
-
export function measureQuality(layout: RenderedLayout): QualityMetrics {
|
|
134
|
-
return {
|
|
135
|
-
edgeCrossings: countEdgeCrossings(layout.edges),
|
|
136
|
-
labelLegibility: measureLabelLegibility(layout.nodes),
|
|
137
|
-
whitespaceBalance: measureWhitespaceBalance(layout),
|
|
138
|
-
labelEdgeProximity: measureLabelEdgeProximity(layout),
|
|
139
|
-
aspectRatio: layout.bounds.h > 0 ? layout.bounds.w / layout.bounds.h : Infinity,
|
|
140
|
-
nodeCount: layout.nodes.length,
|
|
141
|
-
edgeCount: layout.edges.length,
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export interface RankedViolation {
|
|
146
|
-
metric: keyof typeof BOUND_PROVENANCE
|
|
147
|
-
severity: ViolationSeverity
|
|
148
|
-
message: string
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export interface QualityVerdict {
|
|
152
|
-
ok: boolean
|
|
153
|
-
metrics: QualityMetrics
|
|
154
|
-
/** Human-readable violation strings. Backward-compatible. */
|
|
155
|
-
violations: string[]
|
|
156
|
-
/**
|
|
157
|
-
* The same violations, each tagged with its evidence-based severity so a
|
|
158
|
-
* consumer can weight a crossings violation above a whitespace one
|
|
159
|
-
* (Purchase 1997/2002). Ordered primary → secondary → sanity.
|
|
160
|
-
*/
|
|
161
|
-
ranked: RankedViolation[]
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function checkQuality(layout: RenderedLayout, bounds: QualityBounds = {}): QualityVerdict {
|
|
165
|
-
const b = { ...DEFAULT_BOUNDS, ...bounds }
|
|
166
|
-
const m = measureQuality(layout)
|
|
167
|
-
const ranked: RankedViolation[] = []
|
|
168
|
-
const flag = (metric: keyof typeof BOUND_PROVENANCE, message: string) =>
|
|
169
|
-
ranked.push({ metric, severity: BOUND_PROVENANCE[metric].severity, message })
|
|
170
|
-
const pairs = Math.max(1, m.edgeCount * (m.edgeCount - 1) / 2)
|
|
171
|
-
if (m.edgeCrossings / pairs > b.maxCrossingsRatio) {
|
|
172
|
-
flag('edgeCrossings', `edge crossings ${m.edgeCrossings}/${pairs} (${(m.edgeCrossings / pairs * 100).toFixed(1)}%) > cap ${(b.maxCrossingsRatio * 100).toFixed(1)}%`)
|
|
173
|
-
}
|
|
174
|
-
if (m.labelLegibility < b.minLabelLegibility) {
|
|
175
|
-
flag('labelLegibility', `label legibility ${(m.labelLegibility * 100).toFixed(0)}% < min ${(b.minLabelLegibility * 100).toFixed(0)}%`)
|
|
176
|
-
}
|
|
177
|
-
if (m.whitespaceBalance < b.whitespaceBand[0] || m.whitespaceBalance > b.whitespaceBand[1]) {
|
|
178
|
-
flag('whitespaceBalance', `whitespace balance ${(m.whitespaceBalance * 100).toFixed(1)}% outside band [${b.whitespaceBand[0] * 100}–${b.whitespaceBand[1] * 100}]%`)
|
|
179
|
-
}
|
|
180
|
-
if (m.labelEdgeProximity < b.minLabelEdgeProximity) {
|
|
181
|
-
flag('labelEdgeProximity', `edge-label clearance ${m.labelEdgeProximity}px < min ${b.minLabelEdgeProximity}px`)
|
|
182
|
-
}
|
|
183
|
-
if (m.aspectRatio < b.aspectBand[0] || m.aspectRatio > b.aspectBand[1]) {
|
|
184
|
-
flag('aspectRatio', `aspect ratio ${m.aspectRatio.toFixed(2)} outside band [${b.aspectBand[0]}–${b.aspectBand[1]}]`)
|
|
185
|
-
}
|
|
186
|
-
const order: Record<ViolationSeverity, number> = { primary: 0, secondary: 1, sanity: 2 }
|
|
187
|
-
ranked.sort((x, y) => order[x.severity] - order[y.severity])
|
|
188
|
-
return { ok: ranked.length === 0, metrics: m, violations: ranked.map(v => v.message), ranked }
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ---- crossings -----------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
function countEdgeCrossings(edges: RenderedLayoutEdge[]): number {
|
|
194
|
-
let count = 0
|
|
195
|
-
for (let i = 0; i < edges.length; i++) {
|
|
196
|
-
for (let j = i + 1; j < edges.length; j++) {
|
|
197
|
-
const a = edges[i]!, b = edges[j]!
|
|
198
|
-
// Shared endpoints — not a "crossing" in the visual sense.
|
|
199
|
-
const shared = a.from === b.from || a.from === b.to || a.to === b.from || a.to === b.to
|
|
200
|
-
const ap = a.path, bp = b.path
|
|
201
|
-
for (let ai = 0; ai < ap.length - 1; ai++) {
|
|
202
|
-
for (let bi = 0; bi < bp.length - 1; bi++) {
|
|
203
|
-
if (shared && (ai === 0 || ai === ap.length - 2) && (bi === 0 || bi === bp.length - 2)) continue
|
|
204
|
-
if (segmentsCross(ap[ai]!, ap[ai + 1]!, bp[bi]!, bp[bi + 1]!)) count++
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return count
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function segmentsCross(p1: [number, number], p2: [number, number], p3: [number, number], p4: [number, number]): boolean {
|
|
213
|
-
const d = (a: [number, number], b: [number, number], c: [number, number]): number =>
|
|
214
|
-
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
215
|
-
const d1 = d(p3, p4, p1), d2 = d(p3, p4, p2)
|
|
216
|
-
const d3 = d(p1, p2, p3), d4 = d(p1, p2, p4)
|
|
217
|
-
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ---- label legibility -----------------------------------------------------
|
|
221
|
-
|
|
222
|
-
function measureLabelLegibility(nodes: RenderedLayoutNode[]): number {
|
|
223
|
-
if (nodes.length === 0) return 1
|
|
224
|
-
let fits = 0
|
|
225
|
-
for (const n of nodes) {
|
|
226
|
-
if (!n.label) { fits++; continue }
|
|
227
|
-
const renderedWidth = n.label.length * CHAR_PX
|
|
228
|
-
if (renderedWidth <= n.w) fits++
|
|
229
|
-
}
|
|
230
|
-
return fits / nodes.length
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ---- whitespace balance --------------------------------------------------
|
|
234
|
-
|
|
235
|
-
function measureWhitespaceBalance(layout: RenderedLayout): number {
|
|
236
|
-
const total = layout.bounds.w * layout.bounds.h
|
|
237
|
-
if (total <= 0) return 0
|
|
238
|
-
let filled = 0
|
|
239
|
-
for (const n of layout.nodes) filled += n.w * n.h
|
|
240
|
-
return Math.min(1, filled / total)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ---- edge-label proximity ------------------------------------------------
|
|
244
|
-
|
|
245
|
-
interface Rect { x: number; y: number; w: number; h: number }
|
|
246
|
-
type Point = [number, number]
|
|
247
|
-
|
|
248
|
-
function nodeBox(n: RenderedLayoutNode): Rect {
|
|
249
|
-
return { x: n.x, y: n.y, w: n.w, h: n.h }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function edgeLabelBox(label: NonNullable<RenderedLayoutEdge['label']>): Rect {
|
|
253
|
-
const metrics = measureMultilineText(label.text, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel)
|
|
254
|
-
const w = metrics.width + EDGE_LABEL_BOX_PADDING * 2
|
|
255
|
-
const h = metrics.height + EDGE_LABEL_BOX_PADDING * 2
|
|
256
|
-
return { x: label.x - w / 2, y: label.y - h / 2, w, h }
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function rectDistance(a: Rect, b: Rect): number {
|
|
260
|
-
const dx = Math.max(0, Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w)))
|
|
261
|
-
const dy = Math.max(0, Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h)))
|
|
262
|
-
return Math.hypot(dx, dy)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function pointInRect(p: Point, r: Rect): boolean {
|
|
266
|
-
return p[0] >= r.x && p[0] <= r.x + r.w && p[1] >= r.y && p[1] <= r.y + r.h
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function pointToRectDistance(p: Point, r: Rect): number {
|
|
270
|
-
const dx = Math.max(0, Math.max(r.x - p[0], p[0] - (r.x + r.w)))
|
|
271
|
-
const dy = Math.max(0, Math.max(r.y - p[1], p[1] - (r.y + r.h)))
|
|
272
|
-
return Math.hypot(dx, dy)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function pointToSegmentDistance(p: Point, a: Point, b: Point): number {
|
|
276
|
-
const dx = b[0] - a[0]
|
|
277
|
-
const dy = b[1] - a[1]
|
|
278
|
-
const lenSq = dx * dx + dy * dy
|
|
279
|
-
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1])
|
|
280
|
-
|
|
281
|
-
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq))
|
|
282
|
-
const projX = a[0] + t * dx
|
|
283
|
-
const projY = a[1] + t * dy
|
|
284
|
-
return Math.hypot(p[0] - projX, p[1] - projY)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function segmentsIntersectInclusive(a: Point, b: Point, c: Point, d: Point): boolean {
|
|
288
|
-
const orient = (p: Point, q: Point, r: Point): number =>
|
|
289
|
-
(q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
|
290
|
-
const onSegment = (p: Point, q: Point, r: Point): boolean =>
|
|
291
|
-
q[0] >= Math.min(p[0], r[0]) - GEOM_EPS && q[0] <= Math.max(p[0], r[0]) + GEOM_EPS &&
|
|
292
|
-
q[1] >= Math.min(p[1], r[1]) - GEOM_EPS && q[1] <= Math.max(p[1], r[1]) + GEOM_EPS
|
|
293
|
-
|
|
294
|
-
const o1 = orient(a, b, c)
|
|
295
|
-
const o2 = orient(a, b, d)
|
|
296
|
-
const o3 = orient(c, d, a)
|
|
297
|
-
const o4 = orient(c, d, b)
|
|
298
|
-
if (Math.abs(o1) <= GEOM_EPS && onSegment(a, c, b)) return true
|
|
299
|
-
if (Math.abs(o2) <= GEOM_EPS && onSegment(a, d, b)) return true
|
|
300
|
-
if (Math.abs(o3) <= GEOM_EPS && onSegment(c, a, d)) return true
|
|
301
|
-
if (Math.abs(o4) <= GEOM_EPS && onSegment(c, b, d)) return true
|
|
302
|
-
return (o1 > 0) !== (o2 > 0) && (o3 > 0) !== (o4 > 0)
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function segmentIntersectsRect(a: Point, b: Point, r: Rect): boolean {
|
|
306
|
-
if (pointInRect(a, r) || pointInRect(b, r)) return true
|
|
307
|
-
|
|
308
|
-
const topLeft: Point = [r.x, r.y]
|
|
309
|
-
const topRight: Point = [r.x + r.w, r.y]
|
|
310
|
-
const bottomRight: Point = [r.x + r.w, r.y + r.h]
|
|
311
|
-
const bottomLeft: Point = [r.x, r.y + r.h]
|
|
312
|
-
return segmentsIntersectInclusive(a, b, topLeft, topRight) ||
|
|
313
|
-
segmentsIntersectInclusive(a, b, topRight, bottomRight) ||
|
|
314
|
-
segmentsIntersectInclusive(a, b, bottomRight, bottomLeft) ||
|
|
315
|
-
segmentsIntersectInclusive(a, b, bottomLeft, topLeft)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function rectToSegmentDistance(r: Rect, a: Point, b: Point): number {
|
|
319
|
-
if (segmentIntersectsRect(a, b, r)) return 0
|
|
320
|
-
|
|
321
|
-
const corners: Point[] = [
|
|
322
|
-
[r.x, r.y],
|
|
323
|
-
[r.x + r.w, r.y],
|
|
324
|
-
[r.x + r.w, r.y + r.h],
|
|
325
|
-
[r.x, r.y + r.h],
|
|
326
|
-
]
|
|
327
|
-
return Math.min(
|
|
328
|
-
pointToRectDistance(a, r),
|
|
329
|
-
pointToRectDistance(b, r),
|
|
330
|
-
...corners.map(c => pointToSegmentDistance(c, a, b)),
|
|
331
|
-
)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function measureLabelEdgeProximity(layout: RenderedLayout): number {
|
|
335
|
-
let min = Infinity
|
|
336
|
-
const labels = layout.edges
|
|
337
|
-
.filter((e): e is RenderedLayoutEdge & { label: NonNullable<RenderedLayoutEdge['label']> } => e.label !== undefined)
|
|
338
|
-
.map(e => ({ edge: e, box: edgeLabelBox(e.label) }))
|
|
339
|
-
|
|
340
|
-
for (const { edge, box } of labels) {
|
|
341
|
-
for (const n of layout.nodes) {
|
|
342
|
-
if (n.id === edge.from || n.id === edge.to) continue
|
|
343
|
-
const d = rectDistance(box, nodeBox(n))
|
|
344
|
-
if (d < min) min = d
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
for (let i = 0; i < labels.length; i++) {
|
|
348
|
-
for (let j = i + 1; j < labels.length; j++) {
|
|
349
|
-
const d = rectDistance(labels[i]!.box, labels[j]!.box)
|
|
350
|
-
if (d < min) min = d
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
for (const { edge, box } of labels) {
|
|
354
|
-
for (const other of layout.edges) {
|
|
355
|
-
if (other === edge) continue
|
|
356
|
-
for (let i = 0; i < other.path.length - 1; i++) {
|
|
357
|
-
const d = rectToSegmentDistance(box, other.path[i]!, other.path[i + 1]!)
|
|
358
|
-
if (d < min) min = d
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return min
|
|
363
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// Readability audit over the public RenderedLayout.
|
|
2
|
-
//
|
|
3
|
-
// "Readable" here means every text element can actually be read: its glyphs are
|
|
4
|
-
// neither occluded by foreign geometry nor clipped off the canvas. This is the
|
|
5
|
-
// invariant the before/after fan-out screenshots violated — the "yes" label
|
|
6
|
-
// rendered as a clipped "es" because a node box was drawn over it.
|
|
7
|
-
//
|
|
8
|
-
// Two codes, both family-agnostic (they read only the rendered geometry):
|
|
9
|
-
// • LABEL_OCCLUDED — a text element overlaps foreign geometry that hides its
|
|
10
|
-
// glyphs: an edge-label pill over a non-incident node or another edge label,
|
|
11
|
-
// or a LABELLED node's box overlapped by another node.
|
|
12
|
-
// • LABEL_CLIPPED — a text element extends beyond the diagram bounds.
|
|
13
|
-
//
|
|
14
|
-
// A small overlap tolerance (TOL) ignores grazes — a 1px touch is not a
|
|
15
|
-
// readability defect, an occluded glyph is.
|
|
16
|
-
|
|
17
|
-
import type { RenderedLayout, RenderedLayoutNode } from './types.ts'
|
|
18
|
-
import { measureMultilineText } from '../text-metrics.ts'
|
|
19
|
-
import { resolveRenderStyle } from '../styles.ts'
|
|
20
|
-
|
|
21
|
-
export type ReadabilityFinding =
|
|
22
|
-
| { code: 'LABEL_OCCLUDED'; element: string; by: string }
|
|
23
|
-
| { code: 'LABEL_CLIPPED'; element: string }
|
|
24
|
-
|
|
25
|
-
interface Box { x: number; y: number; w: number; h: number }
|
|
26
|
-
|
|
27
|
-
const TOL = 2 // an overlap must exceed this on BOTH axes to count (ignore grazes)
|
|
28
|
-
const PILL_PAD = 8
|
|
29
|
-
const BOUNDS_TOL = 1
|
|
30
|
-
|
|
31
|
-
// Node-LABEL occlusion is only meaningful where a node's `label` is drawn as
|
|
32
|
-
// glyphs inside its box — the node-link families. Chart families render a node's
|
|
33
|
-
// `label` as axis/series metadata (a bar's category lives on the x-axis, not on
|
|
34
|
-
// the bar), so two marks sharing a column is not a text-occlusion. This is a
|
|
35
|
-
// category rule about rendering models, not a per-diagram exception; EDGE-label
|
|
36
|
-
// checks below still run for every family.
|
|
37
|
-
const NODE_LINK_KINDS = new Set(['flowchart', 'state', 'class', 'er', 'architecture'])
|
|
38
|
-
|
|
39
|
-
const overlaps = (a: Box, b: Box): boolean =>
|
|
40
|
-
Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > TOL &&
|
|
41
|
-
Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > TOL
|
|
42
|
-
|
|
43
|
-
const nodeBox = (n: RenderedLayoutNode): Box => ({ x: n.x, y: n.y, w: n.w, h: n.h })
|
|
44
|
-
const hasText = (label: string | undefined): boolean => !!label && label.trim().length > 0
|
|
45
|
-
|
|
46
|
-
export function auditReadability(layout: RenderedLayout): ReadabilityFinding[] {
|
|
47
|
-
const style = resolveRenderStyle({})
|
|
48
|
-
const findings: ReadabilityFinding[] = []
|
|
49
|
-
const within = (b: Box): boolean =>
|
|
50
|
-
b.x >= -BOUNDS_TOL && b.y >= -BOUNDS_TOL &&
|
|
51
|
-
b.x + b.w <= layout.bounds.w + BOUNDS_TOL && b.y + b.h <= layout.bounds.h + BOUNDS_TOL
|
|
52
|
-
|
|
53
|
-
// Edge labels: pill must be in-bounds and clear of non-incident nodes and
|
|
54
|
-
// other edge labels.
|
|
55
|
-
const labelBoxes: Array<{ id: string; box: Box }> = []
|
|
56
|
-
for (const e of layout.edges) {
|
|
57
|
-
if (!e.label) continue
|
|
58
|
-
const m = measureMultilineText(e.label.text, style.edgeLabelFontSize, style.edgeLabelFontWeight)
|
|
59
|
-
const box: Box = { x: e.label.x - (m.width + 2 * PILL_PAD) / 2, y: e.label.y - (m.height + 2 * PILL_PAD) / 2, w: m.width + 2 * PILL_PAD, h: m.height + 2 * PILL_PAD }
|
|
60
|
-
if (!within(box)) findings.push({ code: 'LABEL_CLIPPED', element: e.id })
|
|
61
|
-
for (const n of layout.nodes) {
|
|
62
|
-
if (n.id === e.from || n.id === e.to) continue
|
|
63
|
-
if (overlaps(box, nodeBox(n))) { findings.push({ code: 'LABEL_OCCLUDED', element: e.id, by: n.id }); break }
|
|
64
|
-
}
|
|
65
|
-
for (const other of labelBoxes) {
|
|
66
|
-
if (overlaps(box, other.box)) { findings.push({ code: 'LABEL_OCCLUDED', element: e.id, by: other.id }); break }
|
|
67
|
-
}
|
|
68
|
-
labelBoxes.push({ id: e.id, box })
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Node labels: a labelled node's box must be in-bounds and not overlapped by
|
|
72
|
-
// another node (which would draw over its text). We only consider nodes whose
|
|
73
|
-
// box actually FITS the rendered label — i.e. the text is drawn as glyphs
|
|
74
|
-
// inside the box. This exempts marks whose `label` is metadata rendered
|
|
75
|
-
// elsewhere (a chart bar's category lives on the axis, not on the bar), by the
|
|
76
|
-
// principled "is text drawn here" test rather than a per-family exception.
|
|
77
|
-
const labelFitsBox = (n: RenderedLayoutNode): boolean => {
|
|
78
|
-
const m = measureMultilineText(n.label!, style.nodeLabelFontSize, style.nodeLabelFontWeight)
|
|
79
|
-
return n.w + 1 >= m.width && n.h + 1 >= m.height
|
|
80
|
-
}
|
|
81
|
-
for (const n of NODE_LINK_KINDS.has(layout.kind) ? layout.nodes : []) {
|
|
82
|
-
if (!hasText(n.label) || !labelFitsBox(n)) continue
|
|
83
|
-
const box = nodeBox(n)
|
|
84
|
-
if (!within(box)) findings.push({ code: 'LABEL_CLIPPED', element: n.id })
|
|
85
|
-
for (const other of layout.nodes) {
|
|
86
|
-
if (other.id === n.id) continue
|
|
87
|
-
if (overlaps(box, nodeBox(other))) { findings.push({ code: 'LABEL_OCCLUDED', element: n.id, by: other.id }); break }
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return findings
|
|
92
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// Family-agnostic route audit over the public RenderedLayout.
|
|
2
|
-
//
|
|
3
|
-
// auditRouteContracts (route-contracts.ts) audits the ELK route pipeline using
|
|
4
|
-
// route certificates, so it only applies to the graph families that flow
|
|
5
|
-
// through layoutGraphSync (flowchart, state, legacy). This audits the route
|
|
6
|
-
// invariant that EVERY family's edges must satisfy at the rendered level — an
|
|
7
|
-
// edge label may not sit on a segment that runs collinear with, and overlapping,
|
|
8
|
-
// another edge's segment (ROUTE_LABEL_ON_SHARED_TRUNK, route-contracts §11.4):
|
|
9
|
-
// the reader cannot tell which edge such a label names. It runs on any family's
|
|
10
|
-
// layoutMermaid() output, so the label-on-shared-trunk class can no longer hide
|
|
11
|
-
// in sequence / class / ER / architecture / … layouts the graph audit can't see.
|
|
12
|
-
|
|
13
|
-
import type { RenderedLayout } from './types.ts'
|
|
14
|
-
import { measureMultilineText } from '../text-metrics.ts'
|
|
15
|
-
import { resolveRenderStyle } from '../styles.ts'
|
|
16
|
-
|
|
17
|
-
export type RenderedRouteFinding = { code: 'ROUTE_LABEL_ON_SHARED_TRUNK'; edge: string; sharedWith: string }
|
|
18
|
-
|
|
19
|
-
const EPS = 0.01
|
|
20
|
-
const CLEARANCE = 4
|
|
21
|
-
const PILL_PAD = 8 // matches route-contracts LABEL_PILL_PADDING
|
|
22
|
-
|
|
23
|
-
export function auditRenderedRoutes(layout: RenderedLayout): RenderedRouteFinding[] {
|
|
24
|
-
const style = resolveRenderStyle({})
|
|
25
|
-
const findings: RenderedRouteFinding[] = []
|
|
26
|
-
for (const edge of layout.edges) {
|
|
27
|
-
if (!edge.label) continue
|
|
28
|
-
const m = measureMultilineText(edge.label.text, style.edgeLabelFontSize, style.edgeLabelFontWeight)
|
|
29
|
-
const pw = m.width + 2 * PILL_PAD, ph = m.height + 2 * PILL_PAD
|
|
30
|
-
const px = edge.label.x - pw / 2, py = edge.label.y - ph / 2
|
|
31
|
-
let flagged: string | null = null
|
|
32
|
-
for (const other of layout.edges) {
|
|
33
|
-
if (other === edge || flagged) continue
|
|
34
|
-
for (let i = 1; i < other.path.length && !flagged; i++) {
|
|
35
|
-
const a = other.path[i - 1]!, b = other.path[i]!
|
|
36
|
-
const vertical = Math.abs(a[0] - b[0]) < EPS
|
|
37
|
-
const horizontal = Math.abs(a[1] - b[1]) < EPS
|
|
38
|
-
if (!vertical && !horizontal) continue
|
|
39
|
-
const sxLo = Math.min(a[0], b[0]), sxHi = Math.max(a[0], b[0])
|
|
40
|
-
const syLo = Math.min(a[1], b[1]), syHi = Math.max(a[1], b[1])
|
|
41
|
-
if (!(sxHi >= px && sxLo <= px + pw && syHi >= py && syLo <= py + ph)) continue
|
|
42
|
-
// Shared trunk only when one of THIS edge's segments is collinear with
|
|
43
|
-
// the other's; a plain perpendicular crossing under the pill is fine.
|
|
44
|
-
for (let j = 1; j < edge.path.length && !flagged; j++) {
|
|
45
|
-
const c = edge.path[j - 1]!, d = edge.path[j]!
|
|
46
|
-
const sameAxis = vertical
|
|
47
|
-
? Math.abs(c[0] - d[0]) < EPS && Math.abs(c[0] - a[0]) < CLEARANCE
|
|
48
|
-
: Math.abs(c[1] - d[1]) < EPS && Math.abs(c[1] - a[1]) < CLEARANCE
|
|
49
|
-
if (!sameAxis) continue
|
|
50
|
-
const cLo = vertical ? Math.min(c[1], d[1]) : Math.min(c[0], d[0])
|
|
51
|
-
const cHi = vertical ? Math.max(c[1], d[1]) : Math.max(c[0], d[0])
|
|
52
|
-
const oLo = vertical ? syLo : sxLo
|
|
53
|
-
const oHi = vertical ? syHi : sxHi
|
|
54
|
-
if (Math.min(cHi, oHi) - Math.max(cLo, oLo) > EPS) flagged = other.id
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
if (flagged) findings.push({ code: 'ROUTE_LABEL_ON_SHARED_TRUNK', edge: edge.id, sharedWith: flagged })
|
|
59
|
-
}
|
|
60
|
-
return findings
|
|
61
|
-
}
|