@stacksjs/defaults 0.75.361 → 0.75.362
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/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +2 -2
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import {
|
|
5
|
+
animateAttr,
|
|
6
|
+
focusEdgeAttrs,
|
|
7
|
+
focusNodeAttrs,
|
|
8
|
+
focusNodeTitle,
|
|
9
|
+
loadDiagram,
|
|
10
|
+
svgAccessibleText,
|
|
11
|
+
svgRootAttrs,
|
|
12
|
+
writeDiagram,
|
|
13
|
+
} from '../shared/cli.mjs';
|
|
14
|
+
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
15
|
+
import {
|
|
16
|
+
legendFootprint,
|
|
17
|
+
measureLegend,
|
|
18
|
+
relationshipLegendObstacles,
|
|
19
|
+
resolveLegend,
|
|
20
|
+
renderLegend as renderResolvedLegend,
|
|
21
|
+
} from '../shared/legend.mjs';
|
|
22
|
+
import { nodeTextFit, fittedNodeFontSize } from '../shared/text-fit.mjs';
|
|
23
|
+
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_ER_GRID,
|
|
26
|
+
bandedLayout,
|
|
27
|
+
entityHeight,
|
|
28
|
+
erGridLayout,
|
|
29
|
+
resolveEntityPos,
|
|
30
|
+
resolvedEntityWidth,
|
|
31
|
+
validateErGridPlacement,
|
|
32
|
+
} from './grid.mjs';
|
|
33
|
+
import {
|
|
34
|
+
asArray,
|
|
35
|
+
arrowClassMap,
|
|
36
|
+
cleanAmbiguousCorridorProblems,
|
|
37
|
+
cleanCrossingProblems,
|
|
38
|
+
cleanEndpointSideProblems,
|
|
39
|
+
cleanFlowProblems,
|
|
40
|
+
cleanLabelRouteClearanceProblems,
|
|
41
|
+
cleanRouteRhythmProblems,
|
|
42
|
+
labelPoint,
|
|
43
|
+
legacyDefaultFromSide,
|
|
44
|
+
legacyDefaultToSide,
|
|
45
|
+
rectsOverlap,
|
|
46
|
+
roundedPath,
|
|
47
|
+
routePointsValue,
|
|
48
|
+
segmentIntersectsRect,
|
|
49
|
+
suggestComponentSeparation,
|
|
50
|
+
suggestLabelObstacleFix,
|
|
51
|
+
variantAccent,
|
|
52
|
+
} from '../shared/geometry.mjs';
|
|
53
|
+
import { createRouter } from '../architecture/routing.mjs';
|
|
54
|
+
import { placeAutomaticLabels } from '../shared/automatic-labels.mjs';
|
|
55
|
+
import { entityBox, connectionPath as relationshipPath } from '../shared/layout-report.mjs';
|
|
56
|
+
|
|
57
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const layoutJsonMode = process.argv.includes('--layout-json');
|
|
59
|
+
const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
|
|
60
|
+
const { diagram: er, template, outPath, sourceEvidence } = loadDiagram({
|
|
61
|
+
rendererDir: __dirname,
|
|
62
|
+
diagramType: 'erd',
|
|
63
|
+
defaultExample: 'orders.erd.json',
|
|
64
|
+
argv: cliArgs,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const grid = erGridLayout(er);
|
|
68
|
+
|
|
69
|
+
const layout = {
|
|
70
|
+
margin: 24,
|
|
71
|
+
legendH: 34,
|
|
72
|
+
padX: 12,
|
|
73
|
+
keyWidth: 30,
|
|
74
|
+
keyFont: 9,
|
|
75
|
+
headerFont: 13,
|
|
76
|
+
headerFontMinimum: 10,
|
|
77
|
+
rowFont: 11,
|
|
78
|
+
rowFontMinimum: 9,
|
|
79
|
+
typeFont: 10.5,
|
|
80
|
+
typeFontMinimum: 9,
|
|
81
|
+
minRelationshipLength: 24,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const KEY_ACCENT = { pk: 't-database', fk: 't-messagebus', uk: 't-muted' };
|
|
85
|
+
// Each key role is a tinted badge; the badge carries the role so the stylesheet
|
|
86
|
+
// tints it in the role's own family and the legend hover can find it.
|
|
87
|
+
const KEY_BADGE_W = 22;
|
|
88
|
+
const KEY_BADGE_GAP = 3;
|
|
89
|
+
const KEY_BADGE_INSET = 2;
|
|
90
|
+
|
|
91
|
+
// A column can carry more than one key role: a junction table's tenant_id is
|
|
92
|
+
// both part of the primary key and a foreign key to tenant.id, and stating only
|
|
93
|
+
// one of the two roles would claim less than the schema does. `key` is a role or
|
|
94
|
+
// a list of roles, and a role named twice still draws once.
|
|
95
|
+
const KEY_ROLE_GAP = 5;
|
|
96
|
+
|
|
97
|
+
function keyRoles(attribute) {
|
|
98
|
+
const key = attribute?.key;
|
|
99
|
+
const roles = typeof key === 'string' ? [key] : asArray(key);
|
|
100
|
+
return [...new Set(roles)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The run of badges one row's roles occupy past the column's start (the first
|
|
104
|
+
// badge sits a small inset to the left of it), so the column can be sized from it.
|
|
105
|
+
function keyGlyphWidth(roles) {
|
|
106
|
+
if (!roles.length) return 0;
|
|
107
|
+
return roles.length * KEY_BADGE_W + (roles.length - 1) * KEY_BADGE_GAP - KEY_BADGE_INSET;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One key column per table, floored at the shared width so a table that states a
|
|
111
|
+
// single role keeps the alignment every earlier diagram already has.
|
|
112
|
+
function keyColumnWidth(entity) {
|
|
113
|
+
const widest = asArray(entity?.attributes)
|
|
114
|
+
.reduce((max, attribute) => Math.max(max, keyGlyphWidth(keyRoles(attribute))), 0);
|
|
115
|
+
// The trailing gap is the porch between the last marker and the field name.
|
|
116
|
+
return Math.max(layout.keyWidth, widest ? widest + KEY_ROLE_GAP : 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One glyph per role, each in its legend's own ink so a composite key reads as
|
|
120
|
+
// two known markers rather than one unfamiliar token.
|
|
121
|
+
function renderKeyBadge(role, x, baseline) {
|
|
122
|
+
return `<rect x="${x}" y="${baseline - 10}" width="${KEY_BADGE_W}" height="13" rx="3" class="er-key-badge" data-er-badge="${esc(role)}"/><text data-detail="context" x="${x + KEY_BADGE_W / 2}" y="${baseline}" class="${KEY_ACCENT[role] || 't-muted'}" font-size="${layout.keyFont}" font-weight="600">${esc(role.toUpperCase())}</text>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderKeyGlyphs(attribute, x0, baseline) {
|
|
126
|
+
return keyRoles(attribute)
|
|
127
|
+
.map((role, index) => renderKeyBadge(role, x0 - KEY_BADGE_INSET + index * (KEY_BADGE_W + KEY_BADGE_GAP), baseline))
|
|
128
|
+
.join('');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- Measure entities from the banded grid -----------------------------------
|
|
132
|
+
// Header and row metrics live in the grid contract so the banded layout and the
|
|
133
|
+
// renderer can never disagree about a box's height.
|
|
134
|
+
const metrics = grid ?? DEFAULT_ER_GRID;
|
|
135
|
+
|
|
136
|
+
// Width resolution lives in the grid module so placement and measurement cannot
|
|
137
|
+
// disagree about an entity that omits `width`.
|
|
138
|
+
const entityWidth = (entity) => resolvedEntityWidth(entity, grid);
|
|
139
|
+
|
|
140
|
+
const measuredEntities = asArray(er.entities).map((entity) => {
|
|
141
|
+
const attributes = asArray(entity.attributes);
|
|
142
|
+
const width = entityWidth(entity);
|
|
143
|
+
return {
|
|
144
|
+
...entity,
|
|
145
|
+
attributes,
|
|
146
|
+
width,
|
|
147
|
+
height: entityHeight({ ...entity, attributes }, grid ?? DEFAULT_ER_GRID),
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// A relationship label is semantic data and the smallest text the diagram
|
|
152
|
+
// carries: at the shared 8-unit default it projects to ~7.0px on a tall canvas,
|
|
153
|
+
// under the 7.5px the canvas declares.
|
|
154
|
+
const RELATIONSHIP_LABEL_FONT = 9;
|
|
155
|
+
// The label's advance is measured from that size, not from a literal: the mask
|
|
156
|
+
// the renderer draws, the rect the layout checks measure, and the obstacle the
|
|
157
|
+
// legend measures must be the same rectangle, or a label can sit on a table edge
|
|
158
|
+
// with no diagnostic (the checks would be measuring a narrower box than the ink).
|
|
159
|
+
const RELATIONSHIP_LABEL_ADVANCE = 0.62;
|
|
160
|
+
const relationshipLabelWidth = (label) => Math.max(30, textUnits(label) * RELATIONSHIP_LABEL_FONT * RELATIONSHIP_LABEL_ADVANCE + 10);
|
|
161
|
+
|
|
162
|
+
// A label between two neighbouring columns is drawn in the gap between them:
|
|
163
|
+
// on the straight line of a same-row relationship, or across the channel of a
|
|
164
|
+
// bent one. The fixed gap was narrower than an ordinary two-word label plus the
|
|
165
|
+
// cardinality glyph at each table edge, so most first drafts failed with a
|
|
166
|
+
// label drawn over a table. When the grid owns every coordinate (no pinned
|
|
167
|
+
// position, route, channel, or label point refers to absolute geometry), each
|
|
168
|
+
// such gap widens just enough for its widest label to clear both glyphs.
|
|
169
|
+
const LABEL_GLYPH_CLEARANCE = 24 + 4;
|
|
170
|
+
function labelColumnGaps() {
|
|
171
|
+
if (!grid || Array.isArray(er.meta?.viewBox)) return null;
|
|
172
|
+
const rawEntities = asArray(er.entities);
|
|
173
|
+
const rawRelationships = asArray(er.relationships);
|
|
174
|
+
const absolute = rawEntities.some((entity) => Array.isArray(entity.pos))
|
|
175
|
+
|| rawRelationships.some((relationship) => relationship.via?.length || relationship.labelAt
|
|
176
|
+
|| (relationship.route && relationship.route !== 'auto')
|
|
177
|
+
|| relationship.channelX !== undefined || relationship.channelY !== undefined);
|
|
178
|
+
if (absolute) return null;
|
|
179
|
+
const byId = new Map(rawEntities.map((entity) => [entity.id, entity]));
|
|
180
|
+
const gaps = new Map();
|
|
181
|
+
for (const relationship of rawRelationships) {
|
|
182
|
+
if (!relationship.label) continue;
|
|
183
|
+
const from = byId.get(relationship.from);
|
|
184
|
+
const to = byId.get(relationship.to);
|
|
185
|
+
if (![from?.col, to?.col].every(Number.isInteger) || Math.abs(from.col - to.col) !== 1) continue;
|
|
186
|
+
const gap = Math.min(from.col, to.col);
|
|
187
|
+
const demand = Math.ceil(relationshipLabelWidth(relationship.label) + LABEL_GLYPH_CLEARANCE * 2);
|
|
188
|
+
gaps.set(gap, Math.max(gaps.get(gap) || 0, demand));
|
|
189
|
+
}
|
|
190
|
+
return gaps.size ? gaps : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const bands = grid ? bandedLayout(measuredEntities, grid, { columnGaps: labelColumnGaps() }) : null;
|
|
194
|
+
|
|
195
|
+
const entities = new Map(measuredEntities.map((entity) => {
|
|
196
|
+
const [x, y] = resolveEntityPos(entity, grid, bands);
|
|
197
|
+
return [entity.id, { ...entity, x, y, cx: x + entity.width / 2, cy: y + entity.height / 2 }];
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
const relationships = asArray(er.relationships);
|
|
201
|
+
|
|
202
|
+
// ---- Domain bands ------------------------------------------------------------
|
|
203
|
+
// A `tag` names a domain, and a domain has to read as one block. The band is
|
|
204
|
+
// measured from the placed boxes of the entities that carry the tag, so it
|
|
205
|
+
// follows the authored grid instead of introducing a second layout pass. Only a
|
|
206
|
+
// block the members actually fill earns a band: a tag whose members are spread
|
|
207
|
+
// across the canvas is a grouping the author did not actually make, and drawing
|
|
208
|
+
// a band around the gap would claim a structure the table placement contradicts.
|
|
209
|
+
// Since the band is the only place a domain name is drawn, an unbandable tag is
|
|
210
|
+
// refused rather than published unnamed; see `erd/domain-not-drawn`.
|
|
211
|
+
const DOMAIN_PAD_X = 12;
|
|
212
|
+
const DOMAIN_PAD_TOP = 18;
|
|
213
|
+
const DOMAIN_PAD_BOTTOM = 8;
|
|
214
|
+
|
|
215
|
+
// A domain is drawn as a band when its members fill a solid rectangle of grid
|
|
216
|
+
// cells: every cell of the bounding box holds one of them, so the band encloses
|
|
217
|
+
// no cell the domain does not own. A single row or column, the common case, is
|
|
218
|
+
// that same claim with one dimension of one; an L or diagonal is not contiguous.
|
|
219
|
+
function gridRunIsContiguous(members) {
|
|
220
|
+
const cols = members.map((entity) => entity.col);
|
|
221
|
+
const rows = members.map((entity) => entity.row);
|
|
222
|
+
const cells = new Set(members.map((entity) => `${entity.row}:${entity.col}`));
|
|
223
|
+
if (cells.size !== members.length) return false;
|
|
224
|
+
const box = (Math.max(...cols) - Math.min(...cols) + 1) * (Math.max(...rows) - Math.min(...rows) + 1);
|
|
225
|
+
return cells.size === box;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A band is measured from grid cells, so it can only follow a table that is
|
|
229
|
+
// placed by them. A table that also declares `pos` is drawn at that point
|
|
230
|
+
// whatever its cells say, and banding it would wrap a box the domain does not
|
|
231
|
+
// own around wherever the author put it.
|
|
232
|
+
function placedByGrid(entity) {
|
|
233
|
+
return Number.isInteger(entity.row) && Number.isInteger(entity.col)
|
|
234
|
+
&& !(Array.isArray(entity.pos) && entity.pos.length === 2);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const domainGroups = (() => {
|
|
238
|
+
const byTag = new Map();
|
|
239
|
+
for (const entity of entities.values()) {
|
|
240
|
+
if (!entity.tag) continue;
|
|
241
|
+
if (!byTag.has(entity.tag)) byTag.set(entity.tag, []);
|
|
242
|
+
byTag.get(entity.tag).push(entity);
|
|
243
|
+
}
|
|
244
|
+
const groups = [];
|
|
245
|
+
for (const [tag, members] of byTag) {
|
|
246
|
+
if (!members.every(placedByGrid)) continue;
|
|
247
|
+
if (!gridRunIsContiguous(members)) continue;
|
|
248
|
+
groups.push({ tag, members });
|
|
249
|
+
}
|
|
250
|
+
return groups;
|
|
251
|
+
})();
|
|
252
|
+
|
|
253
|
+
// A band is the only place a domain name is drawn, because the header carries
|
|
254
|
+
// the table title alone. A tag whose members cannot form one therefore loses its
|
|
255
|
+
// name from the canvas while the tables look complete, so the render refuses the
|
|
256
|
+
// document instead of drawing a domain the reader cannot name.
|
|
257
|
+
const unlabelableDomains = (() => {
|
|
258
|
+
const banded = new Set(domainGroups.map((group) => group.tag));
|
|
259
|
+
const byTag = new Map();
|
|
260
|
+
for (const entity of entities.values()) {
|
|
261
|
+
if (!entity.tag || banded.has(entity.tag)) continue;
|
|
262
|
+
if (!byTag.has(entity.tag)) byTag.set(entity.tag, []);
|
|
263
|
+
byTag.get(entity.tag).push(entity);
|
|
264
|
+
}
|
|
265
|
+
return [...byTag.entries()].map(([tag, members]) => {
|
|
266
|
+
const gridPlaced = members.every(placedByGrid);
|
|
267
|
+
// Two members can share a cell, which the grid placement check already
|
|
268
|
+
// reports with the pair that has to move; do not also blame the block.
|
|
269
|
+
const sharedCell = gridPlaced
|
|
270
|
+
&& new Set(members.map((entity) => `${entity.row}:${entity.col}`)).size !== members.length;
|
|
271
|
+
return {
|
|
272
|
+
tag,
|
|
273
|
+
members,
|
|
274
|
+
reason: sharedCell ? 'shared-cell' : gridPlaced ? 'scattered' : 'no-grid',
|
|
275
|
+
offGrid: members.filter((entity) => !placedByGrid(entity)).map((entity) => entity.id),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
})();
|
|
279
|
+
|
|
280
|
+
// The band box and the caption the band draws, measured once so the drawing and
|
|
281
|
+
// the capacity check cannot disagree about where the domain name lands.
|
|
282
|
+
function domainBandBox(members) {
|
|
283
|
+
const minX = Math.min(...members.map((entity) => entity.x));
|
|
284
|
+
const minY = Math.min(...members.map((entity) => entity.y));
|
|
285
|
+
const maxX = Math.max(...members.map((entity) => entity.x + entity.width));
|
|
286
|
+
const maxY = Math.max(...members.map((entity) => entity.y + entity.height));
|
|
287
|
+
return {
|
|
288
|
+
x: minX - DOMAIN_PAD_X,
|
|
289
|
+
y: minY - DOMAIN_PAD_TOP,
|
|
290
|
+
width: maxX - minX + DOMAIN_PAD_X * 2,
|
|
291
|
+
height: maxY - minY + DOMAIN_PAD_TOP + DOMAIN_PAD_BOTTOM,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function domainBandCaption(box) {
|
|
296
|
+
return { x: box.x + 10, y: box.y + 13 };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Each domain owns one hue that its band and its tables share, so a table's
|
|
300
|
+
// domain reads from its header before the band caption is found. Untagged
|
|
301
|
+
// tables keep the schema's database hue.
|
|
302
|
+
const DOMAIN_HUES = ['database', 'frontend', 'backend', 'cloud', 'messagebus', 'security'];
|
|
303
|
+
const domainHue = new Map();
|
|
304
|
+
for (const entity of entities.values()) {
|
|
305
|
+
if (entity.tag && !domainHue.has(entity.tag)) domainHue.set(entity.tag, DOMAIN_HUES[domainHue.size % DOMAIN_HUES.length]);
|
|
306
|
+
}
|
|
307
|
+
const hueClassOf = (tag) => `er-hue-${(tag && domainHue.get(tag)) || 'database'}`;
|
|
308
|
+
|
|
309
|
+
function renderDomainBands() {
|
|
310
|
+
if (!domainGroups.length) return '';
|
|
311
|
+
return domainGroups.map(({ tag, members }) => {
|
|
312
|
+
const box = domainBandBox(members);
|
|
313
|
+
const caption = domainBandCaption(box);
|
|
314
|
+
const pillWidth = Math.ceil(textUnits(tag) * (9 * nodeTextFit.widthFactor + 0.8) + 14);
|
|
315
|
+
return ` <g data-domain-band="${esc(tag)}" class="${hueClassOf(tag)}">
|
|
316
|
+
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="12" class="c-lane er-band" stroke-width="1"/>
|
|
317
|
+
<rect x="${caption.x - 2}" y="${box.y - 7}" width="${pillWidth}" height="15" rx="7.5" class="c-mask"/>
|
|
318
|
+
<rect x="${caption.x - 2}" y="${box.y - 7}" width="${pillWidth}" height="15" rx="7.5" class="er-band-pill"/>
|
|
319
|
+
<text data-domain-label="" x="${caption.x + 5}" y="${box.y + 3.6}" class="t-muted er-band-label" font-size="9" font-weight="700">${esc(tag)}</text>
|
|
320
|
+
</g>`;
|
|
321
|
+
}).join('\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const entitySteps = new Map();
|
|
325
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
326
|
+
if (!entitySteps.has(relationship.from)) entitySteps.set(relationship.from, index);
|
|
327
|
+
if (!entitySteps.has(relationship.to)) entitySteps.set(relationship.to, index + 1);
|
|
328
|
+
}
|
|
329
|
+
for (const [index, entity] of asArray(er.entities).entries()) {
|
|
330
|
+
if (!entitySteps.has(entity.id)) entitySteps.set(entity.id, index);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- Cardinality -------------------------------------------------------------
|
|
334
|
+
// A relationship reads `from` -> `to` and both ends declare their own maximum.
|
|
335
|
+
// The schema requires each one, so an unstated maximum fails validation before
|
|
336
|
+
// rendering instead of reading as a fact the author never asserted; this
|
|
337
|
+
// fallback only keeps an already-invalid diagram from crashing the renderer.
|
|
338
|
+
function cardinalityOf(relationship, endpoint) {
|
|
339
|
+
const value = endpoint === 'from' ? relationship.fromCardinality : relationship.toCardinality;
|
|
340
|
+
if (value === 'one' || value === 'many') return value;
|
|
341
|
+
return endpoint === 'from' ? 'many' : 'one';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function optionalOf(relationship, endpoint) {
|
|
345
|
+
return (endpoint === 'from' ? relationship.fromOptional : relationship.toOptional) === true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// `identifying: false` is a fact about the key and the dashed line is how this
|
|
349
|
+
// renderer draws that fact, so the flag owns the dash. The schema rejects a
|
|
350
|
+
// `variant` that would contradict it; this precedence keeps a document that
|
|
351
|
+
// reached the renderer without passing the schema from drawing the
|
|
352
|
+
// relationship solid.
|
|
353
|
+
function markerStyleOf(relationship) {
|
|
354
|
+
if (relationship.identifying === false) return 'dashed';
|
|
355
|
+
return relationship.variant ?? 'default';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function cardinalityMarkerId(relationship, endpoint) {
|
|
359
|
+
const cardinality = cardinalityOf(relationship, endpoint);
|
|
360
|
+
const optional = optionalOf(relationship, endpoint) ? '-optional' : '';
|
|
361
|
+
return `er-${cardinality}${optional}-${endpoint === 'from' ? 'start' : 'end'}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Crow's foot notation: the foot opens toward the entity whose cardinality it
|
|
365
|
+
// describes. The toes touch that entity at the path endpoint and the apex sits
|
|
366
|
+
// one foot back along the relationship, so the glyph reads as "many rows here"
|
|
367
|
+
// rather than as an arrowhead pointing away from the table it describes.
|
|
368
|
+
// `start` markers are mirrored because an oriented marker's +x axis points
|
|
369
|
+
// along the path direction, which leaves the from-endpoint heading outward.
|
|
370
|
+
// The glyph is drawn in the muted text ink rather than the shared arrow token:
|
|
371
|
+
// at the light theme's arrow colour a crow's foot over a table fill sits near
|
|
372
|
+
// 2:1 contrast and reads as a smudge.
|
|
373
|
+
//
|
|
374
|
+
// The optionality ring sits past the apex rather than inside the toes. Both
|
|
375
|
+
// facts are about the same end, so a ring centred on the foot would be
|
|
376
|
+
// bisected by its middle toe and read as one muddy shape instead of
|
|
377
|
+
// "zero or many".
|
|
378
|
+
const MARKER_GLYPH_WIDTH = 24;
|
|
379
|
+
const MARKER_TOE_X = 22;
|
|
380
|
+
const MARKER_APEX_X = 8;
|
|
381
|
+
const MARKER_CIRCLE_X = 3.5;
|
|
382
|
+
// The glyph spans y 1..15, so two ports closer than this draw overlapping
|
|
383
|
+
// symbols and the side reads as one lattice instead of N cardinalities.
|
|
384
|
+
const MARKER_HEIGHT = 14;
|
|
385
|
+
// One glyph source for the relationship markers and the legend swatches, so the
|
|
386
|
+
// legend can never teach a different symbol than the lines draw.
|
|
387
|
+
function cardinalityGlyphParts({ cardinality, optional, mirror = false }) {
|
|
388
|
+
const flip = (x) => (mirror ? MARKER_TOE_X - x : x);
|
|
389
|
+
const parts = [];
|
|
390
|
+
if (cardinality === 'many') {
|
|
391
|
+
parts.push(`<path d="M ${flip(MARKER_APEX_X)} 8 L ${flip(MARKER_TOE_X)} 1 M ${flip(MARKER_APEX_X)} 8 L ${flip(MARKER_TOE_X)} 8 M ${flip(MARKER_APEX_X)} 8 L ${flip(MARKER_TOE_X)} 15"/>`);
|
|
392
|
+
} else if (cardinality === 'one') {
|
|
393
|
+
parts.push(`<path d="M ${flip(17)} 1 L ${flip(17)} 15"/>`);
|
|
394
|
+
}
|
|
395
|
+
if (optional) {
|
|
396
|
+
// The ring sits on the relationship line, so the line has to be masked
|
|
397
|
+
// under it: a stroked circle alone leaves the dashes running through the
|
|
398
|
+
// middle and the symbol reads as a crossed-out dot.
|
|
399
|
+
parts.push(`<circle cx="${flip(MARKER_CIRCLE_X)}" cy="8" r="2.6" class="c-mask"/>`);
|
|
400
|
+
parts.push(`<circle cx="${flip(MARKER_CIRCLE_X)}" cy="8" r="2.6"/>`);
|
|
401
|
+
}
|
|
402
|
+
return parts;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function cardinalityMarkerMarkup(id, { cardinality, optional, mirror }) {
|
|
406
|
+
const refX = mirror ? 0 : MARKER_TOE_X;
|
|
407
|
+
return ` <marker id="${esc(id)}" markerWidth="${MARKER_GLYPH_WIDTH}" markerHeight="18" refX="${refX}" refY="8" orient="auto" markerUnits="userSpaceOnUse" class="a-default" style="stroke: var(--text-muted)" stroke-width="1.75">
|
|
408
|
+
${cardinalityGlyphParts({ cardinality, optional, mirror }).join('\n ')}
|
|
409
|
+
</marker>`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function renderCardinalityDefs() {
|
|
413
|
+
const used = new Set();
|
|
414
|
+
for (const relationship of relationships) {
|
|
415
|
+
used.add(`${cardinalityOf(relationship, 'from')}|${optionalOf(relationship, 'from')}|start`);
|
|
416
|
+
used.add(`${cardinalityOf(relationship, 'to')}|${optionalOf(relationship, 'to')}|end`);
|
|
417
|
+
}
|
|
418
|
+
const ids = [];
|
|
419
|
+
for (const cardinality of ['one', 'many']) {
|
|
420
|
+
for (const optional of [false, true]) {
|
|
421
|
+
for (const end of ['start', 'end']) {
|
|
422
|
+
ids.push({ id: `er-${cardinality}${optional ? '-optional' : ''}-${end}`, cardinality, optional, mirror: end === 'start', key: `${cardinality}|${optional}|${end}` });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const markup = ids.filter((entry) => used.has(entry.key))
|
|
427
|
+
.map((entry) => cardinalityMarkerMarkup(entry.id, entry));
|
|
428
|
+
if (!markup.length) return '';
|
|
429
|
+
return `\n <!-- Cardinality symbols -->\n${markup.join('\n')}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- Legend ------------------------------------------------------------------
|
|
433
|
+
// The ERD kinds are column keys and relationship ends, not `data-node-kind`
|
|
434
|
+
// values, so the Viewer's counted node lens would mark every entry as zero.
|
|
435
|
+
// The entries stay static and highlight their matches in the SVG on hover.
|
|
436
|
+
const LEGEND_CATALOG = ['pk', 'fk', 'uk', 'one', 'many', 'optional']
|
|
437
|
+
.map((kind) => ({ kind, label: i18nText(er.meta.locale, `legend.erd.${kind}`), interactive: false }));
|
|
438
|
+
const KEY_KINDS = new Set(['pk', 'fk', 'uk']);
|
|
439
|
+
|
|
440
|
+
// The kinds one relationship shows at its two ends; the legend hover matches
|
|
441
|
+
// a route and its label by this list.
|
|
442
|
+
function relationshipEnds(relationship) {
|
|
443
|
+
const ends = new Set([cardinalityOf(relationship, 'from'), cardinalityOf(relationship, 'to')]);
|
|
444
|
+
if (optionalOf(relationship, 'from') || optionalOf(relationship, 'to')) ends.add('optional');
|
|
445
|
+
return [...ends];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const presentKinds = new Set();
|
|
449
|
+
for (const entity of entities.values()) {
|
|
450
|
+
for (const attribute of entity.attributes) {
|
|
451
|
+
for (const role of keyRoles(attribute)) presentKinds.add(role);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
for (const relationship of relationships) {
|
|
455
|
+
for (const kind of relationshipEnds(relationship)) presentKinds.add(kind);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// The reserved legend band: its baseline sits this far above the canvas bottom,
|
|
459
|
+
// and its labels are set at this size. Both are part of the band's footprint.
|
|
460
|
+
const LEGEND_BASELINE_GAP = 16;
|
|
461
|
+
const LEGEND_FONT_SIZE = 10;
|
|
462
|
+
const LEGEND_KEY_SWATCH = 22;
|
|
463
|
+
const LEGEND_END_SWATCH = 30;
|
|
464
|
+
// Extra space before the first relationship entry that follows a key entry, so
|
|
465
|
+
// the divider between the column and relationship groups has room on both sides.
|
|
466
|
+
const LEGEND_GROUP_LEAD = 10;
|
|
467
|
+
|
|
468
|
+
// The shared renderer sets labels two units above the measured font size, so
|
|
469
|
+
// each entry reserves that difference and the hover pill never overlaps its
|
|
470
|
+
// neighbour. Computed from the resolved label, so an authored label is measured.
|
|
471
|
+
const erLegendEntries = resolveLegend(er.meta?.legend, LEGEND_CATALOG, presentKinds).map((entry, index, all) => {
|
|
472
|
+
const isKey = KEY_KINDS.has(entry.kind);
|
|
473
|
+
const lead = !isKey && index > 0 && KEY_KINDS.has(all[index - 1].kind) ? LEGEND_GROUP_LEAD : 0;
|
|
474
|
+
return {
|
|
475
|
+
...entry,
|
|
476
|
+
lead,
|
|
477
|
+
swatchWidth: (isKey ? LEGEND_KEY_SWATCH : LEGEND_END_SWATCH) + lead,
|
|
478
|
+
trailingWidth: Math.ceil(textUnits(entry.label) * 2 * 0.62),
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// One layout object for the reserved legend band, shared by the sizing probe
|
|
483
|
+
// and the render call so the drawing area is never sized against a different
|
|
484
|
+
// strip than the one the legend is placed into.
|
|
485
|
+
function legendLayout(width, height, { obstacles = [], unfit = 'error' } = {}) {
|
|
486
|
+
const contentBottom = Math.max(0, ...[...entities.values()].map((entity) => entity.y + entity.height));
|
|
487
|
+
return {
|
|
488
|
+
x: layout.margin,
|
|
489
|
+
baselineY: height - LEGEND_BASELINE_GAP,
|
|
490
|
+
width: width - layout.margin * 2,
|
|
491
|
+
fontSize: LEGEND_FONT_SIZE,
|
|
492
|
+
minTitleY: contentBottom + 8,
|
|
493
|
+
obstacles,
|
|
494
|
+
unfit,
|
|
495
|
+
diagramType: 'erd',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The default legend is part of the generated content, so the drawing area is
|
|
500
|
+
// sized to hold it: a reserved strip that is a few units short grows the
|
|
501
|
+
// canvas instead of dropping the key. An authored `meta.viewBox` is a fixed
|
|
502
|
+
// area, and there the same measurement raises the capacity diagnostic.
|
|
503
|
+
function autoViewBox() {
|
|
504
|
+
const maxX = Math.max(0, ...[...entities.values()].map((entity) => entity.x + entity.width));
|
|
505
|
+
const maxY = Math.max(0, ...[...entities.values()].map((entity) => entity.y + entity.height));
|
|
506
|
+
let width = Math.ceil(maxX + layout.margin);
|
|
507
|
+
let footprint = legendFootprint(erLegendEntries, { width: Math.max(1, width - layout.margin * 2) });
|
|
508
|
+
if (footprint.minWidth > width - layout.margin * 2) {
|
|
509
|
+
width = Math.ceil(footprint.minWidth + layout.margin * 2);
|
|
510
|
+
footprint = legendFootprint(erLegendEntries, { width: width - layout.margin * 2 });
|
|
511
|
+
}
|
|
512
|
+
let height = Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight);
|
|
513
|
+
// The shared measurement decides whether the strip fits; each attempt adds a
|
|
514
|
+
// whole band, so the loop ends after at most a few rounds.
|
|
515
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
516
|
+
if (measureLegend(erLegendEntries, legendLayout(width, height, { unfit: 'hide' }))) break;
|
|
517
|
+
height += layout.legendH;
|
|
518
|
+
}
|
|
519
|
+
return [width, height];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// The legends are measured before routing exists (this runs at module load), so
|
|
523
|
+
// the probe covers the sizing failure modes — label width and band height. A
|
|
524
|
+
// route or label that later collides with the placed band is a real capacity
|
|
525
|
+
// failure and keeps its diagnostic.
|
|
526
|
+
const viewBox = Array.isArray(er.meta?.viewBox) ? er.meta.viewBox : autoViewBox();
|
|
527
|
+
|
|
528
|
+
// ---- Routing -----------------------------------------------------------------
|
|
529
|
+
// Relationships that would share one corridor get a deterministic lane each, so
|
|
530
|
+
// two routes never sit on the identical channel line. The lane is only a
|
|
531
|
+
// preference: a lane that breaks the endpoint contract or hits an entity is
|
|
532
|
+
// skipped and the shared candidate families run unchanged.
|
|
533
|
+
const LANE_STEP = 12;
|
|
534
|
+
|
|
535
|
+
function relationshipLabelBox(relationship) {
|
|
536
|
+
const [lx, ly] = erLabelPoint(relationship);
|
|
537
|
+
const width = relationshipLabelWidth(relationship.label);
|
|
538
|
+
return { x: lx - width / 2, y: ly - 10, width, height: 14 };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// A cardinality glyph is 14 units tall, so two relationships leaving the same
|
|
542
|
+
// table side must sit at least this far apart: the shared 14px spread put them
|
|
543
|
+
// close enough to read as one shape. The trunk bridge below is derived from it.
|
|
544
|
+
const ERD_PORT_SPACING = 24;
|
|
545
|
+
|
|
546
|
+
const bandKey = (entity) => (
|
|
547
|
+
Number.isInteger(entity.row) && Number.isInteger(entity.col)
|
|
548
|
+
? `i${entity.row},${entity.col}`
|
|
549
|
+
: `p${Math.round(entity.x || 0)},${Math.round(entity.y || 0)}`
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const { ports, pathFor, inferredSides, connectionEndpointSide } = createRouter(entities, relationships, {
|
|
553
|
+
// Horizontal-first, like every other non-architecture type: a relationship
|
|
554
|
+
// leaves and enters on the left/right when the tables sit side by side and
|
|
555
|
+
// only falls back to a top/bottom port when they share a column. The shared
|
|
556
|
+
// router defaults to architecture's dominant-axis inference, which would flip
|
|
557
|
+
// the side of a relationship whose targets happen to sit further apart
|
|
558
|
+
// vertically, splitting one fan-in into two groups nobody authored.
|
|
559
|
+
sideFor: (relationship, endpoint) => {
|
|
560
|
+
const from = entities.get(relationship.from);
|
|
561
|
+
const to = entities.get(relationship.to);
|
|
562
|
+
if (!from || !to) return undefined;
|
|
563
|
+
return endpoint === 'source' ? legacyDefaultFromSide(from, to) : legacyDefaultToSide(from, to);
|
|
564
|
+
},
|
|
565
|
+
preferredCandidates: (context) => [...trunkCandidates(context), ...laneCandidates(context)],
|
|
566
|
+
// A bundled trunk is drawn as one bus path and the trunk stretch is removed
|
|
567
|
+
// from each branch's drawn route, so the router's rhythm floors — which
|
|
568
|
+
// describe a route drawn exactly as planned — do not describe what this
|
|
569
|
+
// renderer draws. The composition gate still measures the drawn result.
|
|
570
|
+
compositionFloors: false,
|
|
571
|
+
// A cardinality glyph is 14 units tall, so ports must clear it (see
|
|
572
|
+
// ERD_PORT_SPACING, which the trunk bridge is derived from).
|
|
573
|
+
maxPortSpacing: ERD_PORT_SPACING,
|
|
574
|
+
// Greedy planning routes each relationship against only the earlier ones, so
|
|
575
|
+
// a later route could cross an earlier corridor while an empty side of the
|
|
576
|
+
// table was free. Architecture's bounded readability sweep re-plans one
|
|
577
|
+
// route at a time against the complete scene and keeps a change only when it
|
|
578
|
+
// removes crossings or bends at bounded extra length.
|
|
579
|
+
preferReadableRoutes: true,
|
|
580
|
+
// A proper crossing fails the showcase gate, so a relationship that needs
|
|
581
|
+
// the obstacle search first looks for a detour that crosses nothing.
|
|
582
|
+
crossingFreeGridFirst: true,
|
|
583
|
+
// A detour keeps a readable gap from every table it only passes instead of
|
|
584
|
+
// running along its border at the grid's 2-unit clearance.
|
|
585
|
+
componentGapPx: 10,
|
|
586
|
+
// Corners are drawn with a 6-unit radius; an end segment shorter than the
|
|
587
|
+
// radius plus the 8-unit micro-segment floor fails the drawn-route gate.
|
|
588
|
+
minimumTerminalSegmentPx: 14,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
function corridorKey(relationship) {
|
|
592
|
+
const from = entities.get(relationship.from);
|
|
593
|
+
const to = entities.get(relationship.to);
|
|
594
|
+
if (!from || !to) return null;
|
|
595
|
+
const { fromSide, toSide } = inferredSides(relationship);
|
|
596
|
+
const horizontalPorts = (fromSide === 'left' || fromSide === 'right');
|
|
597
|
+
const verticalPorts = (fromSide === 'top' || fromSide === 'bottom');
|
|
598
|
+
if (horizontalPorts === verticalPorts) return null;
|
|
599
|
+
const ordered = horizontalPorts
|
|
600
|
+
? [from, to].sort((left, right) => left.cx - right.cx)
|
|
601
|
+
: [from, to].sort((left, right) => left.cy - right.cy);
|
|
602
|
+
return `${horizontalPorts ? 'h' : 'v'}:${bandKey(ordered[0])}|${bandKey(ordered[1])}`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const laneOffsets = new Map();
|
|
606
|
+
{
|
|
607
|
+
const groups = new Map();
|
|
608
|
+
for (const relationship of relationships) {
|
|
609
|
+
const key = corridorKey(relationship);
|
|
610
|
+
if (!key) continue;
|
|
611
|
+
const list = groups.get(key) || [];
|
|
612
|
+
list.push(relationship);
|
|
613
|
+
groups.set(key, list);
|
|
614
|
+
}
|
|
615
|
+
for (const list of groups.values()) {
|
|
616
|
+
if (list.length < 2) continue;
|
|
617
|
+
list.sort((left, right) => {
|
|
618
|
+
const leftKey = `${left.from}\u0000${left.to}\u0000${left.id || ''}`;
|
|
619
|
+
const rightKey = `${right.from}\u0000${right.to}\u0000${right.id || ''}`;
|
|
620
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
621
|
+
});
|
|
622
|
+
for (const [index, relationship] of list.entries()) {
|
|
623
|
+
laneOffsets.set(relationship, index - (list.length - 1) / 2);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---- Bundled fan-in / fan-out trunks ------------------------------------------
|
|
629
|
+
// Relationships that share one entity side read better as a single trunk with
|
|
630
|
+
// short branches than as a sheaf of parallel lines (bus-style fan-in). A trunk
|
|
631
|
+
// group shares the resolved side, the marker style, and automatic routing;
|
|
632
|
+
// anything authored (route/via/labelAt) keeps its own line. The trunk is only
|
|
633
|
+
// a preferred candidate: a route that would break the endpoint contract or
|
|
634
|
+
// clip an entity falls back to the ordinary families and stays unbundled.
|
|
635
|
+
const TRUNK_OFFSETS = [16, 22, 12, 28];
|
|
636
|
+
const TRUNK_SNAP = LANE_STEP;
|
|
637
|
+
// One port step of uncovered bus is a bus with a gap in it: the branches were
|
|
638
|
+
// spread by ERD_PORT_SPACING, so the gap they leave is exactly that wide. Two
|
|
639
|
+
// steps means the runs belong to different groups and stay separate lines.
|
|
640
|
+
const TRUNK_BRIDGE = ERD_PORT_SPACING + 8;
|
|
641
|
+
const TRUNK_CAP = 10;
|
|
642
|
+
const TRUNK_LEG = 12;
|
|
643
|
+
const DEFAULT_RELATIONSHIP_STROKE_WIDTH = 1.5;
|
|
644
|
+
|
|
645
|
+
function relationshipStrokeWidth(relationship) {
|
|
646
|
+
return Number.isFinite(relationship.width) ? relationship.width : DEFAULT_RELATIONSHIP_STROKE_WIDTH;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const isHorizontalSide = (side) => (side === 'left' || side === 'right');
|
|
650
|
+
|
|
651
|
+
function relationshipAutoRouted(relationship) {
|
|
652
|
+
return renderableRelationship(relationship)
|
|
653
|
+
&& !relationship.via
|
|
654
|
+
&& (!relationship.route || relationship.route === 'auto')
|
|
655
|
+
&& relationship.channelX === undefined
|
|
656
|
+
&& relationship.channelY === undefined
|
|
657
|
+
&& !relationship.labelAt;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function sideAnchor(entity, side) {
|
|
661
|
+
if (side === 'left') return [entity.x, entity.cy];
|
|
662
|
+
if (side === 'right') return [entity.x + entity.width, entity.cy];
|
|
663
|
+
if (side === 'top') return [entity.cx, entity.y];
|
|
664
|
+
return [entity.cx, entity.y + entity.height];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const trunkGroups = [];
|
|
668
|
+
const trunkAssignments = new Map();
|
|
669
|
+
{
|
|
670
|
+
const fanIn = new Map();
|
|
671
|
+
const fanOut = new Map();
|
|
672
|
+
const resolvedSides = new Map();
|
|
673
|
+
const push = (map, key, entry) => {
|
|
674
|
+
const list = map.get(key) || [];
|
|
675
|
+
list.push(entry);
|
|
676
|
+
map.set(key, list);
|
|
677
|
+
};
|
|
678
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
679
|
+
if (!relationshipAutoRouted(relationship)) continue;
|
|
680
|
+
const { fromSide, toSide } = inferredSides(relationship);
|
|
681
|
+
resolvedSides.set(relationship, { fromSide, toSide });
|
|
682
|
+
const styleKey = markerStyleOf(relationship);
|
|
683
|
+
push(fanIn, `${relationship.to}\u0000${toSide}\u0000${styleKey}`, { index, relationship });
|
|
684
|
+
push(fanOut, `${relationship.from}\u0000${fromSide}\u0000${styleKey}`, { index, relationship });
|
|
685
|
+
}
|
|
686
|
+
const taken = new Set();
|
|
687
|
+
const buildGroups = (map, role) => {
|
|
688
|
+
const grouped = [...map.values()]
|
|
689
|
+
.filter((list) => list.length >= 2)
|
|
690
|
+
.sort((left, right) => left[0].index - right[0].index);
|
|
691
|
+
for (const list of grouped) {
|
|
692
|
+
const members = list.filter((entry) => !taken.has(entry.relationship));
|
|
693
|
+
if (members.length < 2) continue;
|
|
694
|
+
const head = members[0].relationship;
|
|
695
|
+
const sides = resolvedSides.get(head);
|
|
696
|
+
const group = {
|
|
697
|
+
role,
|
|
698
|
+
entity: entities.get(role === 'in' ? head.to : head.from),
|
|
699
|
+
side: role === 'in' ? sides.toSide : sides.fromSide,
|
|
700
|
+
styleKey: markerStyleOf(head),
|
|
701
|
+
members: members.map((entry) => entry.relationship),
|
|
702
|
+
};
|
|
703
|
+
for (const entry of members) {
|
|
704
|
+
taken.add(entry.relationship);
|
|
705
|
+
trunkAssignments.set(entry.relationship, { group });
|
|
706
|
+
}
|
|
707
|
+
trunkGroups.push(group);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
buildGroups(fanIn, 'in');
|
|
711
|
+
buildGroups(fanOut, 'out');
|
|
712
|
+
|
|
713
|
+
// One trunk coordinate per group, just outside the shared side. A coordinate
|
|
714
|
+
// close to an already chosen trunk snaps onto it, so fan-in and fan-out
|
|
715
|
+
// groups sharing one channel read as a single bus with drops on both sides.
|
|
716
|
+
const chosenLines = [];
|
|
717
|
+
for (const group of trunkGroups) {
|
|
718
|
+
group.axis = isHorizontalSide(group.side) ? 0 : 1;
|
|
719
|
+
const portAxis = group.axis === 0 ? 1 : 0;
|
|
720
|
+
const coordinates = group.members.map((relationship) => {
|
|
721
|
+
const port = ports.get(relationship)?.[group.role === 'in' ? 'to' : 'from'];
|
|
722
|
+
return (port || sideAnchor(group.entity, group.side))[portAxis];
|
|
723
|
+
});
|
|
724
|
+
const low = Math.min(...coordinates) - TRUNK_CAP;
|
|
725
|
+
const high = Math.max(...coordinates) + TRUNK_CAP;
|
|
726
|
+
const clearsSpan = (coordinate) => {
|
|
727
|
+
const segment = group.axis === 0
|
|
728
|
+
? { start: [coordinate, low], end: [coordinate, high] }
|
|
729
|
+
: { start: [low, coordinate], end: [high, coordinate] };
|
|
730
|
+
return [...entities.values()].every((entity) => !segmentIntersectsRect(segment, entity));
|
|
731
|
+
};
|
|
732
|
+
const base = isHorizontalSide(group.side)
|
|
733
|
+
? (group.side === 'left' ? group.entity.x : group.entity.x + group.entity.width)
|
|
734
|
+
: (group.side === 'top' ? group.entity.y : group.entity.y + group.entity.height);
|
|
735
|
+
const outward = group.side === 'left' || group.side === 'top' ? -1 : 1;
|
|
736
|
+
for (const offset of TRUNK_OFFSETS) {
|
|
737
|
+
const candidate = base + outward * offset;
|
|
738
|
+
if (!clearsSpan(candidate)) continue;
|
|
739
|
+
const near = chosenLines.find((line) => line.axis === group.axis
|
|
740
|
+
&& line.styleKey === group.styleKey
|
|
741
|
+
&& Math.abs(line.coordinate - candidate) < TRUNK_SNAP);
|
|
742
|
+
// A snapped coordinate serves another group's span; it must clear this
|
|
743
|
+
// one too, otherwise the next offset is tried.
|
|
744
|
+
if (near && !clearsSpan(near.coordinate)) continue;
|
|
745
|
+
// A differently styled trunk never shares a coordinate: the bus would
|
|
746
|
+
// have to render in one group's dash language, so the next offset is
|
|
747
|
+
// tried until the two lines are a lane apart.
|
|
748
|
+
const clashing = chosenLines.some((line) => line.axis === group.axis
|
|
749
|
+
&& line.styleKey !== group.styleKey
|
|
750
|
+
&& Math.abs(line.coordinate - candidate) < TRUNK_SNAP);
|
|
751
|
+
if (clashing) continue;
|
|
752
|
+
group.coordinate = near ? near.coordinate : candidate;
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (group.coordinate === undefined) {
|
|
756
|
+
for (const relationship of group.members) trunkAssignments.delete(relationship);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
chosenLines.push({ axis: group.axis, coordinate: group.coordinate, styleKey: group.styleKey });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function trunkCandidates(context) {
|
|
764
|
+
const candidate = trunkCandidateShape(context);
|
|
765
|
+
return candidate ? [candidate] : [];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function trunkCandidateShape({ conn, start, end, fromSide, toSide }) {
|
|
769
|
+
const assignment = trunkAssignments.get(conn);
|
|
770
|
+
if (!assignment) return null;
|
|
771
|
+
const { axis, coordinate } = assignment.group;
|
|
772
|
+
const sideOffset = (side) => (side === 'right' || side === 'bottom' ? 1 : -1);
|
|
773
|
+
if (axis === 0) {
|
|
774
|
+
const startHorizontal = isHorizontalSide(fromSide);
|
|
775
|
+
const endHorizontal = isHorizontalSide(toSide);
|
|
776
|
+
if (startHorizontal && endHorizontal) return [[coordinate, start[1]], [coordinate, end[1]]];
|
|
777
|
+
if (startHorizontal) {
|
|
778
|
+
const approachY = end[1] + sideOffset(toSide) * TRUNK_LEG;
|
|
779
|
+
return [[coordinate, start[1]], [coordinate, approachY], [end[0], approachY]];
|
|
780
|
+
}
|
|
781
|
+
if (endHorizontal) {
|
|
782
|
+
const exitY = start[1] + sideOffset(fromSide) * TRUNK_LEG;
|
|
783
|
+
return [[start[0], exitY], [coordinate, exitY], [coordinate, end[1]]];
|
|
784
|
+
}
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
const startHorizontal = isHorizontalSide(fromSide);
|
|
788
|
+
const endHorizontal = isHorizontalSide(toSide);
|
|
789
|
+
if (!startHorizontal && !endHorizontal) return [[start[0], coordinate], [end[0], coordinate]];
|
|
790
|
+
if (!startHorizontal) {
|
|
791
|
+
const approachX = end[0] + sideOffset(toSide) * TRUNK_LEG;
|
|
792
|
+
return [[start[0], coordinate], [approachX, coordinate], [approachX, end[1]]];
|
|
793
|
+
}
|
|
794
|
+
if (!endHorizontal) {
|
|
795
|
+
const exitX = start[0] + sideOffset(fromSide) * TRUNK_LEG;
|
|
796
|
+
return [[exitX, start[1]], [exitX, coordinate], [end[0], coordinate]];
|
|
797
|
+
}
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// The rendered `d` skips each trunk stretch — one shared trunk path carries it
|
|
802
|
+
// — while the logical points keep it, so every gate, label, and legend check
|
|
803
|
+
// still reasons about the full route.
|
|
804
|
+
const trunkRunCache = new Map();
|
|
805
|
+
function relationshipTrunkRuns(relationship) {
|
|
806
|
+
const assignment = trunkAssignments.get(relationship);
|
|
807
|
+
if (!assignment) return [];
|
|
808
|
+
if (trunkRunCache.has(relationship)) return trunkRunCache.get(relationship);
|
|
809
|
+
const { axis, coordinate, role, side } = assignment.group;
|
|
810
|
+
// The readability sweep may move a member to another table side after the
|
|
811
|
+
// groups were formed. Such a route no longer leaves through the shared side,
|
|
812
|
+
// so it is drawn whole instead of losing a coincidental stretch to the bus.
|
|
813
|
+
if (connectionEndpointSide(relationship, role === 'in' ? 'target' : 'source') !== side) {
|
|
814
|
+
trunkRunCache.set(relationship, []);
|
|
815
|
+
return [];
|
|
816
|
+
}
|
|
817
|
+
const points = pathFor(relationship).points;
|
|
818
|
+
const runs = [];
|
|
819
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
820
|
+
const [a, b] = [points[index], points[index + 1]];
|
|
821
|
+
if (Math.abs(a[axis] - coordinate) > 0.5 || Math.abs(b[axis] - coordinate) > 0.5) continue;
|
|
822
|
+
const low = Math.min(a[1 - axis], b[1 - axis]);
|
|
823
|
+
const high = Math.max(a[1 - axis], b[1 - axis]);
|
|
824
|
+
if (high - low < 1) continue;
|
|
825
|
+
const last = runs[runs.length - 1];
|
|
826
|
+
if (last && last.endIndex === index) {
|
|
827
|
+
last.endIndex = index + 1;
|
|
828
|
+
last.high = high;
|
|
829
|
+
} else {
|
|
830
|
+
runs.push({ startIndex: index, endIndex: index + 1, low, high });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
trunkRunCache.set(relationship, runs);
|
|
834
|
+
return runs;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function bundledRelationshipD(routed, runs) {
|
|
838
|
+
if (!runs.length) return routed.d;
|
|
839
|
+
const inRun = new Set();
|
|
840
|
+
for (const run of runs) {
|
|
841
|
+
for (let index = run.startIndex; index < run.endIndex; index += 1) inRun.add(index);
|
|
842
|
+
}
|
|
843
|
+
const pieces = [];
|
|
844
|
+
let current = [routed.points[0]];
|
|
845
|
+
for (let index = 0; index < routed.points.length - 1; index += 1) {
|
|
846
|
+
if (inRun.has(index)) {
|
|
847
|
+
pieces.push(current);
|
|
848
|
+
current = null;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (current === null) current = [routed.points[index]];
|
|
852
|
+
current.push(routed.points[index + 1]);
|
|
853
|
+
}
|
|
854
|
+
if (current) pieces.push(current);
|
|
855
|
+
return pieces
|
|
856
|
+
.filter((piece) => piece.length >= 2)
|
|
857
|
+
.map((piece) => roundedPath(piece, 8))
|
|
858
|
+
.join(' ');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function bundledTrunkPaths() {
|
|
862
|
+
const clusters = new Map();
|
|
863
|
+
for (const relationship of relationships) {
|
|
864
|
+
const runs = relationshipTrunkRuns(relationship);
|
|
865
|
+
if (!runs.length) continue;
|
|
866
|
+
const { axis, coordinate } = trunkAssignments.get(relationship).group;
|
|
867
|
+
const styleKey = markerStyleOf(relationship);
|
|
868
|
+
// Style is part of the cluster key so differently styled groups can never
|
|
869
|
+
// share one trunk path even if their coordinates ever coincide.
|
|
870
|
+
const key = `${axis}:${coordinate}:${styleKey}`;
|
|
871
|
+
const list = clusters.get(key) || [];
|
|
872
|
+
for (const run of runs) {
|
|
873
|
+
list.push({ low: run.low, high: run.high, styleKey, relationship });
|
|
874
|
+
}
|
|
875
|
+
clusters.set(key, list);
|
|
876
|
+
}
|
|
877
|
+
const paths = [];
|
|
878
|
+
const bridged = [];
|
|
879
|
+
const routed = relationships.filter(renderableRelationship)
|
|
880
|
+
.map((relationship) => ({ relationship, points: pathFor(relationship).points, width: relationshipStrokeWidth(relationship) }));
|
|
881
|
+
for (const [key, runs] of clusters) {
|
|
882
|
+
const [axis, coordinate] = key.split(':').slice(0, 2).map(Number);
|
|
883
|
+
const members = new Set(runs.map((run) => run.relationship));
|
|
884
|
+
runs.sort((left, right) => left.low - right.low);
|
|
885
|
+
const merged = [];
|
|
886
|
+
for (const run of runs) {
|
|
887
|
+
const last = merged[merged.length - 1];
|
|
888
|
+
if (last && run.low <= last.high + 0.5) last.high = Math.max(last.high, run.high);
|
|
889
|
+
else merged.push({ ...run });
|
|
890
|
+
}
|
|
891
|
+
// Branches that interleave leave short uncovered stretches on an otherwise
|
|
892
|
+
// continuous bus. Bridge them when the bridged line stays clear of every
|
|
893
|
+
// entity; anything wider stays separate so no phantom corridor is drawn.
|
|
894
|
+
const segments = [];
|
|
895
|
+
for (const segment of merged) {
|
|
896
|
+
const last = segments[segments.length - 1];
|
|
897
|
+
const gap = last ? segment.low - last.high : Infinity;
|
|
898
|
+
if (last && gap > 0.5 && gap <= TRUNK_BRIDGE) {
|
|
899
|
+
const probe = axis === 0
|
|
900
|
+
? { start: [coordinate, last.high], end: [coordinate, segment.low] }
|
|
901
|
+
: { start: [last.high, coordinate], end: [segment.low, coordinate] };
|
|
902
|
+
// 桥接不在任何逻辑关系中,必须检查它实际增加的墨迹。同簇成员可以共享总线;
|
|
903
|
+
// 无关路线和已补出的其他桥接一旦交叉、相接或重叠,就保留两段独立主干。
|
|
904
|
+
const box = {
|
|
905
|
+
x: probe.start[0], y: probe.start[1],
|
|
906
|
+
width: probe.end[0] - probe.start[0], height: probe.end[1] - probe.start[1],
|
|
907
|
+
};
|
|
908
|
+
// 两条线的半宽之和才是描边的接触边界,显式加粗关系也使用实际绘制宽度。
|
|
909
|
+
const conflicts = routed.some(({ relationship, points, width }) => !members.has(relationship)
|
|
910
|
+
&& points.slice(1).some((end, index) => segmentIntersectsRect(
|
|
911
|
+
{ start: points[index], end }, box, (DEFAULT_RELATIONSHIP_STROKE_WIDTH + width) / 2,
|
|
912
|
+
)))
|
|
913
|
+
|| bridged.some((other) => segmentIntersectsRect(other, box, DEFAULT_RELATIONSHIP_STROKE_WIDTH));
|
|
914
|
+
if (!conflicts && ![...entities.values()].some((entity) => segmentIntersectsRect(probe, entity))) {
|
|
915
|
+
bridged.push(probe);
|
|
916
|
+
last.high = segment.high;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
segments.push({ ...segment });
|
|
921
|
+
}
|
|
922
|
+
for (const segment of segments) {
|
|
923
|
+
const [cls] = arrowClassMap[segment.styleKey] || arrowClassMap.default;
|
|
924
|
+
const points = axis === 0
|
|
925
|
+
? [[coordinate, segment.low], [coordinate, segment.high]]
|
|
926
|
+
: [[segment.low, coordinate], [segment.high, coordinate]];
|
|
927
|
+
paths.push(` <path data-er-trunk="" data-composition-points="${routePointsValue(points)}" d="${roundedPath(points, 8)}" class="${cls}" stroke-width="${DEFAULT_RELATIONSHIP_STROKE_WIDTH}"/>`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return paths.join('\n');
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Bundled labels default to the source-side stub so they never sit on the
|
|
934
|
+
// shared trunk, where they would read as annotating every branch at once.
|
|
935
|
+
function defaultErLabelPoint(relationship) {
|
|
936
|
+
const points = pathFor(relationship).points;
|
|
937
|
+
if (!trunkAssignments.get(relationship)) return labelPoint(relationship, points);
|
|
938
|
+
return labelPoint({ ...relationship, labelSegment: relationship.labelSegment ?? 0 }, points);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const resolvedLabelPoints = new Map();
|
|
942
|
+
function erLabelPoint(relationship) {
|
|
943
|
+
return resolvedLabelPoints.get(relationship) ?? defaultErLabelPoint(relationship);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ---- Automatic label placement ----------------------------------------------
|
|
947
|
+
// The default point is the midpoint of one segment, which knows nothing about
|
|
948
|
+
// the tables, glyphs, or other routes around it: a vertical relationship put
|
|
949
|
+
// its label on the source table's cardinality glyph, and a label in a busy gap
|
|
950
|
+
// sat over a neighbouring table or channel. The shared bounded placer (the one
|
|
951
|
+
// architecture and dataflow use) keeps every clear default and moves only a
|
|
952
|
+
// colliding unpinned label to the nearest clear spot beside its own route.
|
|
953
|
+
// Each relationship end reserves its cardinality glyph, and a bundled member's
|
|
954
|
+
// shared trunk counts as another route, so a moved label never annotates the
|
|
955
|
+
// bus or covers a crow's foot.
|
|
956
|
+
// Half the glyph's 16-unit height plus the 2-unit overlap the shared placer
|
|
957
|
+
// tolerates before it moves a label, so even a default spot keeps clear ink.
|
|
958
|
+
const GLYPH_HALF_HEIGHT = 10;
|
|
959
|
+
function cardinalityGlyphBox(point, side) {
|
|
960
|
+
const [x, y] = point;
|
|
961
|
+
const reach = MARKER_GLYPH_WIDTH;
|
|
962
|
+
if (side === 'left') return { x: x - reach, y: y - GLYPH_HALF_HEIGHT, width: reach, height: GLYPH_HALF_HEIGHT * 2 };
|
|
963
|
+
if (side === 'right') return { x, y: y - GLYPH_HALF_HEIGHT, width: reach, height: GLYPH_HALF_HEIGHT * 2 };
|
|
964
|
+
if (side === 'top') return { x: x - GLYPH_HALF_HEIGHT, y: y - reach, width: GLYPH_HALF_HEIGHT * 2, height: reach };
|
|
965
|
+
return { x: x - GLYPH_HALF_HEIGHT, y, width: GLYPH_HALF_HEIGHT * 2, height: reach };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
{
|
|
969
|
+
const placeable = relationships.filter(renderableRelationship);
|
|
970
|
+
const labels = placeable.filter((relationship) => relationship.label).map((relationship) => {
|
|
971
|
+
const [lx, ly] = defaultErLabelPoint(relationship);
|
|
972
|
+
const width = relationshipLabelWidth(relationship.label);
|
|
973
|
+
return {
|
|
974
|
+
relation: relationship, relationIndex: relationships.indexOf(relationship), label: relationship.label,
|
|
975
|
+
x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly,
|
|
976
|
+
};
|
|
977
|
+
});
|
|
978
|
+
if (labels.length) {
|
|
979
|
+
const routes = [];
|
|
980
|
+
const glyphs = [];
|
|
981
|
+
placeable.forEach((relationship) => {
|
|
982
|
+
const relationIndex = relationships.indexOf(relationship);
|
|
983
|
+
const { points } = pathFor(relationship);
|
|
984
|
+
glyphs.push(cardinalityGlyphBox(points[0], connectionEndpointSide(relationship, 'source')));
|
|
985
|
+
glyphs.push(cardinalityGlyphBox(points.at(-1), connectionEndpointSide(relationship, 'target')));
|
|
986
|
+
const runs = relationshipTrunkRuns(relationship);
|
|
987
|
+
if (!runs.length) {
|
|
988
|
+
routes.push({ relationIndex, points });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const inRun = new Set(runs.flatMap((run) => Array.from({ length: run.endIndex - run.startIndex }, (_, step) => run.startIndex + step)));
|
|
992
|
+
let piece = [points[0]];
|
|
993
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
994
|
+
if (inRun.has(index)) {
|
|
995
|
+
if (piece.length > 1) routes.push({ relationIndex, points: piece });
|
|
996
|
+
routes.push({ relationIndex: -1 - routes.length, points: [points[index], points[index + 1]] });
|
|
997
|
+
piece = [points[index + 1]];
|
|
998
|
+
} else {
|
|
999
|
+
piece.push(points[index + 1]);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (piece.length > 1) routes.push({ relationIndex, points: piece });
|
|
1003
|
+
});
|
|
1004
|
+
const titles = domainGroups.map(({ tag, members }) => {
|
|
1005
|
+
const caption = domainBandCaption(domainBandBox(members));
|
|
1006
|
+
return { x: caption.x - 2, y: caption.y - 10, width: textUnits(tag) * 9 * 0.65 + 4, height: 13 };
|
|
1007
|
+
});
|
|
1008
|
+
const footprint = legendFootprint(erLegendEntries, { width: Math.max(1, viewBox[0] - layout.margin * 2) });
|
|
1009
|
+
const placed = placeAutomaticLabels({
|
|
1010
|
+
labels,
|
|
1011
|
+
routes,
|
|
1012
|
+
components: [...entities.values(), ...glyphs],
|
|
1013
|
+
titles,
|
|
1014
|
+
viewBox,
|
|
1015
|
+
placementBottom: viewBox[1] - layout.legendH - footprint.extraHeight,
|
|
1016
|
+
keepFallbackNearRoute: true,
|
|
1017
|
+
});
|
|
1018
|
+
placed.forEach((rect, index) => {
|
|
1019
|
+
if (rect !== labels[index]) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function channelFallback({ conn, start, end, fromSide, toSide }) {
|
|
1025
|
+
const horizontalPorts = fromSide === 'left' || fromSide === 'right';
|
|
1026
|
+
const verticalPorts = fromSide === 'top' || fromSide === 'bottom';
|
|
1027
|
+
if (horizontalPorts === verticalPorts) return null;
|
|
1028
|
+
const axis = horizontalPorts ? 0 : 1;
|
|
1029
|
+
const toPoints = (channel) => (horizontalPorts
|
|
1030
|
+
? [[channel, start[1]], [channel, end[1]]]
|
|
1031
|
+
: [[start[0], channel], [end[0], channel]]);
|
|
1032
|
+
return { axis, toPoints, low: Math.min(start[axis], end[axis]), high: Math.max(start[axis], end[axis]) };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function laneCandidates(context) {
|
|
1036
|
+
const lane = laneOffsets.get(context.conn);
|
|
1037
|
+
if (lane === undefined) return [];
|
|
1038
|
+
const channel = channelFallback(context);
|
|
1039
|
+
if (!channel) return [];
|
|
1040
|
+
const base = (context.start[channel.axis] + context.end[channel.axis]) / 2;
|
|
1041
|
+
return [channel.toPoints(base + lane * LANE_STEP)];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ---- Validation --------------------------------------------------------------
|
|
1045
|
+
function renderableRelationship(relationship) {
|
|
1046
|
+
return entities.has(relationship.from) && entities.has(relationship.to);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function rowTextWidths(attribute) {
|
|
1050
|
+
const name = textUnits(attribute.name) * nodeTextFit.widthFactor;
|
|
1051
|
+
const type = attribute.type ? textUnits(attribute.type) * nodeTextFit.widthFactor : 0;
|
|
1052
|
+
return { name, type };
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// The header carries one string: the table title. A domain that earns a band is
|
|
1056
|
+
// named by the band, and a tagged table the renderer cannot band is refused
|
|
1057
|
+
// outright, so nothing depends on the header to carry a name.
|
|
1058
|
+
function entityHeader(entity) {
|
|
1059
|
+
const titleInset = 30;
|
|
1060
|
+
const titleWidth = entity.width - titleInset - layout.padX;
|
|
1061
|
+
const labelFont = fittedNodeFontSize(entity.label, titleWidth + nodeTextFit.horizontalPadding, layout.headerFont, layout.headerFontMinimum);
|
|
1062
|
+
const labelWidth = textUnits(entity.label) * labelFont * nodeTextFit.widthFactor;
|
|
1063
|
+
if (labelWidth > titleWidth) return { problem: true, titleWidth };
|
|
1064
|
+
// The title is centered on the table's own center line. It is measured against
|
|
1065
|
+
// a narrower interval than the box — the sigil porch on the left, the padding
|
|
1066
|
+
// on the right — so a title that nearly fills that interval is held inside it
|
|
1067
|
+
// rather than left to reach either edge.
|
|
1068
|
+
const leftEdge = entity.x + titleInset;
|
|
1069
|
+
const rightEdge = leftEdge + titleWidth;
|
|
1070
|
+
const labelX = Math.round(Math.min(Math.max(entity.x + entity.width / 2, leftEdge + labelWidth / 2), rightEdge - labelWidth / 2) * 10) / 10;
|
|
1071
|
+
return { labelFont, labelY: 19, labelX };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function validateEr() {
|
|
1075
|
+
const problems = [];
|
|
1076
|
+
// A self-relationship is reported once below; route checks skip it so they
|
|
1077
|
+
// do not suggest side or route repairs for a line that is never drawn.
|
|
1078
|
+
const routableRelationships = relationships.map((relationship) => (
|
|
1079
|
+
relationship.from === relationship.to ? null : relationship));
|
|
1080
|
+
const headerDetails = [];
|
|
1081
|
+
const domainDetails = [];
|
|
1082
|
+
const attributeDetails = [];
|
|
1083
|
+
for (const entity of entities.values()) {
|
|
1084
|
+
const header = entityHeader(entity);
|
|
1085
|
+
if (!header.problem) continue;
|
|
1086
|
+
const message = `Entity "${entity.id}" title does not fit the header at a readable size — widen the entity or shorten the title.`;
|
|
1087
|
+
problems.push(message);
|
|
1088
|
+
headerDetails.push({
|
|
1089
|
+
code: 'erd/header-text-capacity', severity: 'error', message,
|
|
1090
|
+
subject: { diagramType: 'erd', entityId: entity.id },
|
|
1091
|
+
evidence: { label: entity.label, availableWidth: header.titleWidth, headerHeight: metrics.headerH },
|
|
1092
|
+
supportedFixes: ['Widen the entity.', 'Shorten the title.'],
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
for (const domain of unlabelableDomains) {
|
|
1096
|
+
// A shared cell is already reported by the grid placement check, with the
|
|
1097
|
+
// pair that has to move; blaming the block as well would give the author two
|
|
1098
|
+
// fixes for one mistake.
|
|
1099
|
+
if (domain.reason === 'shared-cell') continue;
|
|
1100
|
+
const members = domain.members.map((entity) => entity.id);
|
|
1101
|
+
const listed = members.map((id) => `"${id}"`).join(', ');
|
|
1102
|
+
const offGrid = domain.offGrid.map((id) => `"${id}"`).join(', ');
|
|
1103
|
+
const message = domain.reason === 'no-grid'
|
|
1104
|
+
? `Domain "${domain.tag}" is named but cannot be drawn: ${offGrid} have no grid cells, and a band is measured from them. Give every member row/col cells, or drop the tag.`
|
|
1105
|
+
: `Domain "${domain.tag}" is named but cannot be drawn: ${listed} do not fill a solid rectangle of grid cells (a row, a column, or a filled block — not an L or diagonal), and a band around them would enclose cells the domain does not own. Move them into one block, or split the tag.`;
|
|
1106
|
+
problems.push(message);
|
|
1107
|
+
domainDetails.push({
|
|
1108
|
+
code: 'erd/domain-not-drawn', severity: 'error', message,
|
|
1109
|
+
subject: { diagramType: 'erd', entityId: members[0], domain: domain.tag },
|
|
1110
|
+
evidence: { domain: domain.tag, entities: members, reason: domain.reason, offGrid: domain.offGrid },
|
|
1111
|
+
supportedFixes: domain.reason === 'no-grid'
|
|
1112
|
+
? ['Give every domain member row/col cells.', 'Drop the tag to group nothing.']
|
|
1113
|
+
: ['Give the domain members one solid rectangle of grid cells (a row, a column, or a filled block — not an L).', 'Split the tag so each domain can be drawn as a band.', 'Drop the tag to group nothing.'],
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
// The band draws the domain name, so a band the canvas cannot show would put
|
|
1117
|
+
// the domain back to unnamed. The caption is what has to land inside; it sits
|
|
1118
|
+
// a few units into the band from its top-left corner.
|
|
1119
|
+
for (const { tag, members } of domainGroups) {
|
|
1120
|
+
const caption = domainBandCaption(domainBandBox(members));
|
|
1121
|
+
if (caption.x >= 4 && caption.y >= 4 && caption.y <= viewBox[1] - 4 && caption.x <= viewBox[0] - 4) continue;
|
|
1122
|
+
const listed = members.map((entity) => `"${entity.id}"`).join(', ');
|
|
1123
|
+
const message = `Domain "${tag}" is named but cannot be drawn: the canvas does not reach the band caption for ${listed} at (${caption.x}, ${caption.y}). Move the domain clear of the canvas edge, or drop the tag.`;
|
|
1124
|
+
problems.push(message);
|
|
1125
|
+
domainDetails.push({
|
|
1126
|
+
code: 'erd/domain-not-drawn', severity: 'error', message,
|
|
1127
|
+
subject: { diagramType: 'erd', entityId: members[0].id, domain: tag },
|
|
1128
|
+
evidence: { domain: tag, entities: members.map((entity) => entity.id), reason: 'off-canvas', caption, viewBox },
|
|
1129
|
+
supportedFixes: ['Move the domain clear of the canvas edge.', 'Drop the tag to group nothing.'],
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
// A band claims the box it covers. With gaps smaller than the band padding the
|
|
1133
|
+
// box reaches a table the domain does not own, which is the same false claim as
|
|
1134
|
+
// a band drawn across a gap.
|
|
1135
|
+
for (const { tag, members } of domainGroups) {
|
|
1136
|
+
const memberIds = new Set(members.map((entity) => entity.id));
|
|
1137
|
+
const box = domainBandBox(members);
|
|
1138
|
+
const intruder = [...entities.values()].find((entity) => !memberIds.has(entity.id)
|
|
1139
|
+
&& entity.x < box.x + box.width && entity.x + entity.width > box.x
|
|
1140
|
+
&& entity.y < box.y + box.height && entity.y + entity.height > box.y);
|
|
1141
|
+
if (!intruder) continue;
|
|
1142
|
+
const listed = members.map((entity) => `"${entity.id}"`).join(', ');
|
|
1143
|
+
const message = `Domain "${tag}" is named but cannot be drawn: its band would cover the unrelated table "${intruder.id}". Widen layout.gapX/gapY past the band padding, or split the tag.`;
|
|
1144
|
+
problems.push(message);
|
|
1145
|
+
domainDetails.push({
|
|
1146
|
+
code: 'erd/domain-not-drawn', severity: 'error', message,
|
|
1147
|
+
subject: { diagramType: 'erd', entityId: members[0].id, domain: tag },
|
|
1148
|
+
evidence: { domain: tag, entities: members.map((entity) => entity.id), reason: 'band-covers-entity', band: box, coveredEntity: intruder.id },
|
|
1149
|
+
supportedFixes: ['Widen layout.gapX/gapY past the band padding.', 'Split the tag so each band covers only its own tables.'],
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
validateErGridPlacement(er, grid, bands, problems);
|
|
1153
|
+
const seenEntityIds = new Set();
|
|
1154
|
+
for (const [entityIndex, entity] of asArray(er.entities).entries()) {
|
|
1155
|
+
if (seenEntityIds.has(entity.id)) problems.push(`Entity ids must be unique; "${entity.id}" is declared twice.`);
|
|
1156
|
+
seenEntityIds.add(entity.id);
|
|
1157
|
+
|
|
1158
|
+
if (!Number.isFinite(entity.width) && entity.width !== undefined) {
|
|
1159
|
+
problems.push(`Entity "${entity.id}" width must be a finite number.`);
|
|
1160
|
+
}
|
|
1161
|
+
const seenColumns = new Set();
|
|
1162
|
+
for (const [attributeIndex, attribute] of asArray(entity.attributes).entries()) {
|
|
1163
|
+
if (seenColumns.has(attribute.name)) {
|
|
1164
|
+
problems.push(`Entity "${entity.id}" declares attribute "${attribute.name}" twice.`);
|
|
1165
|
+
}
|
|
1166
|
+
seenColumns.add(attribute.name);
|
|
1167
|
+
const { name: nameUnits, type: typeUnits } = rowTextWidths(attribute);
|
|
1168
|
+
// entity.width is optional; validateEr sees the authored object, so the
|
|
1169
|
+
// overflow check must use the resolved width or an omitted width turns the
|
|
1170
|
+
// available space into NaN and silently skips the diagnostic.
|
|
1171
|
+
const available = entityWidth(entity) - layout.padX * 2 - keyColumnWidth(entity);
|
|
1172
|
+
const needed = nameUnits * layout.rowFont + (typeUnits ? typeUnits * layout.typeFont + 8 : 0);
|
|
1173
|
+
if (needed > available) {
|
|
1174
|
+
const requiredEntityWidth = Math.ceil(needed + layout.padX * 2 + keyColumnWidth(entity));
|
|
1175
|
+
const fix = `For this attribute row's text capacity, set entities[${entityIndex}].width to at least ${requiredEntityWidth}px, preserving the attribute name, type and key roles.`;
|
|
1176
|
+
const message = `Entity "${entity.id}" attribute ${attributeIndex} "${attribute.name}" needs ${Math.ceil(needed)}px of text but only ${Math.floor(available)}px is available — ${fix}`;
|
|
1177
|
+
problems.push(message);
|
|
1178
|
+
attributeDetails.push({
|
|
1179
|
+
code: 'erd/attribute-text-capacity', severity: 'error', message,
|
|
1180
|
+
subject: { diagramType: 'erd', entityId: entity.id, attributeIndex, attributeName: attribute.name },
|
|
1181
|
+
evidence: {
|
|
1182
|
+
entityWidth: entityWidth(entity), requiredEntityWidth,
|
|
1183
|
+
availableTextWidth: available, requiredTextWidth: needed,
|
|
1184
|
+
paddingWidth: layout.padX * 2, keyColumnWidth: keyColumnWidth(entity),
|
|
1185
|
+
nameFontSize: layout.rowFont, typeFontSize: layout.typeFont,
|
|
1186
|
+
},
|
|
1187
|
+
supportedFixes: [fix],
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
for (const entity of entities.values()) {
|
|
1194
|
+
for (const other of entities.values()) {
|
|
1195
|
+
if (entity.id >= other.id) continue;
|
|
1196
|
+
if (!rectsOverlap(entity, other, 0)) continue;
|
|
1197
|
+
problems.push(`Entity "${entity.id}" overlaps entity "${other.id}".\n${suggestComponentSeparation(entity, other)}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
1202
|
+
const id = relationship.id ? ` id "${relationship.id}"` : '';
|
|
1203
|
+
if (!entities.has(relationship.from)) {
|
|
1204
|
+
problems.push(`Relationship ${index}${id} references unknown source entity "${relationship.from}".`);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (!entities.has(relationship.to)) {
|
|
1208
|
+
problems.push(`Relationship ${index}${id} references unknown target entity "${relationship.to}".`);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (relationship.from === relationship.to) {
|
|
1212
|
+
problems.push(`Relationship ${index}${id} connects entity "${relationship.from}" to itself — draw it between two entities or describe the self-reference in a card.`);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const routed = pathFor(relationship);
|
|
1216
|
+
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
|
1217
|
+
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
1218
|
+
if (distance < layout.minRelationshipLength) {
|
|
1219
|
+
problems.push(`Relationship ${index}${id} "${relationship.from}" -> "${relationship.to}" is too short (${Math.round(distance)}px; minimum ${layout.minRelationshipLength}px) — place the entities farther apart.`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
for (const [index, entity] of asArray(er.entities).entries()) {
|
|
1224
|
+
for (const [attributeIndex, attribute] of asArray(entity.attributes).entries()) {
|
|
1225
|
+
if (typeof attribute.references !== 'string') continue;
|
|
1226
|
+
const [targetId, targetColumn] = attribute.references.split('.');
|
|
1227
|
+
const target = entities.get(targetId);
|
|
1228
|
+
if (!target) {
|
|
1229
|
+
problems.push(`Entity "${entity.id}" attribute ${attributeIndex} "${attribute.name}" references unknown entity "${targetId}" in "${attribute.references}".`);
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (!asArray(target.attributes).some((candidate) => candidate.name === targetColumn)) {
|
|
1233
|
+
problems.push(`Entity "${entity.id}" attribute ${attributeIndex} "${attribute.name}" references unknown attribute "${attribute.references}" — "${targetId}" has no attribute "${targetColumn}".`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// A busy fan-in can leave less room than a glyph is tall. The marker spread
|
|
1239
|
+
// is a cap, not a floor: `automaticPortSpread` spaces a side by
|
|
1240
|
+
// `min(maxSpacing, (extent - 2 x gutter) / (n - 1))`, so a short table with
|
|
1241
|
+
// several relationships draws overlapping feet and bars. The gate cannot see
|
|
1242
|
+
// that (the routes are legal), so the renderer names it here.
|
|
1243
|
+
const portSpacingDetails = [];
|
|
1244
|
+
for (const [entityId, entity] of entities) {
|
|
1245
|
+
const sides = new Map();
|
|
1246
|
+
for (const relationship of relationships) {
|
|
1247
|
+
if (!relationshipAutoRouted(relationship)) continue;
|
|
1248
|
+
const { fromSide, toSide } = inferredSides(relationship);
|
|
1249
|
+
if (relationship.from === entityId) sides.set(fromSide, (sides.get(fromSide) || 0) + 1);
|
|
1250
|
+
if (relationship.to === entityId) sides.set(toSide, (sides.get(toSide) || 0) + 1);
|
|
1251
|
+
}
|
|
1252
|
+
for (const [side, count] of sides) {
|
|
1253
|
+
if (count < 2) continue;
|
|
1254
|
+
const extent = side === 'left' || side === 'right' ? entity.height : entity.width;
|
|
1255
|
+
const spacing = Math.min(ERD_PORT_SPACING, Math.max(0, extent - 32) / (count - 1));
|
|
1256
|
+
if (spacing >= MARKER_HEIGHT) continue;
|
|
1257
|
+
const message = `Entity "${entityId}" shares its ${side} side with ${count} relationship ends: their ports would sit ${Math.round(spacing)}px apart, under the ${MARKER_HEIGHT}px a cardinality glyph is tall, so the symbols overlap. Give the table more rows so that side is taller, spread the relationships across sides with room, or reduce the fan-in.`;
|
|
1258
|
+
problems.push(message);
|
|
1259
|
+
portSpacingDetails.push({
|
|
1260
|
+
code: 'layout/marker-capacity',
|
|
1261
|
+
severity: 'error',
|
|
1262
|
+
message,
|
|
1263
|
+
subject: { diagramType: 'erd', path: `/entities/${entityId}` },
|
|
1264
|
+
evidence: { entity: entityId, side, relationshipEnds: count, spacingPx: Math.round(spacing), markerHeightPx: MARKER_HEIGHT, sideExtentPx: extent },
|
|
1265
|
+
supportedFixes: [
|
|
1266
|
+
`add fields to "${entityId}" so its ${side} side is taller`,
|
|
1267
|
+
'spread the relationships across the sides that have room',
|
|
1268
|
+
'reduce the fan-in by modelling the shared parent instead of pointing every child at this table',
|
|
1269
|
+
],
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
problems.push(...cleanEndpointSideProblems({
|
|
1275
|
+
relations: routableRelationships,
|
|
1276
|
+
endpointIds: new Set(entities.keys()),
|
|
1277
|
+
pathFor,
|
|
1278
|
+
diagramType: 'erd',
|
|
1279
|
+
relationCollection: 'relationships',
|
|
1280
|
+
fromSideFor: (relationship) => connectionEndpointSide(relationship, 'source'),
|
|
1281
|
+
toSideFor: (relationship) => connectionEndpointSide(relationship, 'target'),
|
|
1282
|
+
routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide',
|
|
1283
|
+
}));
|
|
1284
|
+
problems.push(...cleanFlowProblems({
|
|
1285
|
+
relations: routableRelationships,
|
|
1286
|
+
obstacles: entities.values(),
|
|
1287
|
+
pathFor,
|
|
1288
|
+
diagramType: 'erd',
|
|
1289
|
+
relationCollection: 'relationships',
|
|
1290
|
+
obstacleKind: 'entity',
|
|
1291
|
+
routeHint: 'adjust fromSide/toSide, set route/via, or move the entity',
|
|
1292
|
+
}));
|
|
1293
|
+
problems.push(...cleanCrossingProblems({
|
|
1294
|
+
relations: routableRelationships,
|
|
1295
|
+
endpointIds: new Set(entities.keys()),
|
|
1296
|
+
pathFor,
|
|
1297
|
+
diagramType: 'erd',
|
|
1298
|
+
relationCollection: 'relationships',
|
|
1299
|
+
profile: er.meta?.quality_profile,
|
|
1300
|
+
routeHint: 'move the entities so unrelated relationships use separate corridors — put a junction beside both parents inside one solid domain rectangle',
|
|
1301
|
+
}));
|
|
1302
|
+
problems.push(...cleanAmbiguousCorridorProblems({
|
|
1303
|
+
relations: routableRelationships,
|
|
1304
|
+
endpointIds: new Set(entities.keys()),
|
|
1305
|
+
pathFor,
|
|
1306
|
+
diagramType: 'erd',
|
|
1307
|
+
relationCollection: 'relationships',
|
|
1308
|
+
profile: er.meta?.quality_profile,
|
|
1309
|
+
routeHint: 'give the relationships different fromSide/toSide targets, or move an entity so their corridors differ',
|
|
1310
|
+
}));
|
|
1311
|
+
problems.push(...cleanRouteRhythmProblems({
|
|
1312
|
+
relations: routableRelationships,
|
|
1313
|
+
endpointIds: new Set(entities.keys()),
|
|
1314
|
+
pathFor,
|
|
1315
|
+
diagramType: 'erd',
|
|
1316
|
+
relationCollection: 'relationships',
|
|
1317
|
+
profile: er.meta?.quality_profile,
|
|
1318
|
+
routeHint: 'widen the gap between bands so every turn has room to read',
|
|
1319
|
+
}));
|
|
1320
|
+
|
|
1321
|
+
const labelRects = [];
|
|
1322
|
+
for (const [index, relationship] of relationships.entries()) {
|
|
1323
|
+
if (!relationship.label || !renderableRelationship(relationship)) continue;
|
|
1324
|
+
const [lx, ly] = erLabelPoint(relationship);
|
|
1325
|
+
labelRects.push({
|
|
1326
|
+
relation: relationship,
|
|
1327
|
+
relationIndex: index,
|
|
1328
|
+
label: relationship.label,
|
|
1329
|
+
...relationshipLabelBox(relationship),
|
|
1330
|
+
lx,
|
|
1331
|
+
ly,
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
for (const rect of labelRects) {
|
|
1335
|
+
for (const entity of entities.values()) {
|
|
1336
|
+
if (!rectsOverlap(rect, entity, -2)) continue;
|
|
1337
|
+
problems.push(`Label "${rect.label}" overlaps entity "${entity.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, entity, 'entity')}`);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
problems.push(...cleanLabelRouteClearanceProblems({
|
|
1341
|
+
relations: routableRelationships,
|
|
1342
|
+
labels: labelRects,
|
|
1343
|
+
endpointIds: new Set(entities.keys()),
|
|
1344
|
+
pathFor,
|
|
1345
|
+
diagramType: 'erd',
|
|
1346
|
+
relationCollection: 'relationships',
|
|
1347
|
+
profile: er.meta?.quality_profile,
|
|
1348
|
+
}));
|
|
1349
|
+
|
|
1350
|
+
if (problems.length) {
|
|
1351
|
+
throwDiagnosticProblems('Entity-relationship layout validation failed', problems, {
|
|
1352
|
+
code: 'layout/constraint',
|
|
1353
|
+
subject: { diagramType: 'erd' },
|
|
1354
|
+
diagnostics: [...headerDetails, ...domainDetails, ...portSpacingDetails, ...attributeDetails],
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function buildLayoutReport() {
|
|
1360
|
+
return {
|
|
1361
|
+
diagram_type: 'erd',
|
|
1362
|
+
viewBox,
|
|
1363
|
+
layout,
|
|
1364
|
+
entities: [...entities.values()].map(entityBox),
|
|
1365
|
+
relationships: relationships
|
|
1366
|
+
.filter(renderableRelationship)
|
|
1367
|
+
.map((relationship) => ({
|
|
1368
|
+
...relationshipPath(relationship, pathFor(relationship), relationship.labelAt || resolvedLabelPoints.get(relationship)),
|
|
1369
|
+
fromCardinality: cardinalityOf(relationship, 'from'),
|
|
1370
|
+
toCardinality: cardinalityOf(relationship, 'to'),
|
|
1371
|
+
})),
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// ---- Rendering ---------------------------------------------------------------
|
|
1376
|
+
function renderEntityColumnRows(entity) {
|
|
1377
|
+
const keyColumn = keyColumnWidth(entity);
|
|
1378
|
+
return entity.attributes.map((attribute, index) => {
|
|
1379
|
+
const rowTop = entity.y + metrics.headerH + index * metrics.rowH;
|
|
1380
|
+
const baseline = rowTop + metrics.rowH - 5;
|
|
1381
|
+
// The row carries `data-detail="context"` as a group, and each text repeats
|
|
1382
|
+
// it: the Viewer's reader sizes the diagram from the texts that declare the
|
|
1383
|
+
// level themselves, so a field that is only context inside a group would be
|
|
1384
|
+
// shrunk past the readable floor on a tall schema.
|
|
1385
|
+
const keyGlyph = renderKeyGlyphs(attribute, entity.x + layout.padX, baseline);
|
|
1386
|
+
const nameX = entity.x + layout.padX + keyColumn;
|
|
1387
|
+
const typeSpace = attribute.type ? Math.min(96, textUnits(attribute.type) * layout.typeFont * nodeTextFit.widthFactor) : 0;
|
|
1388
|
+
const nameFontSize = fittedNodeFontSize(
|
|
1389
|
+
attribute.name,
|
|
1390
|
+
Math.max(40, entity.x + entity.width - layout.padX - typeSpace - nameX),
|
|
1391
|
+
layout.rowFont,
|
|
1392
|
+
layout.rowFontMinimum,
|
|
1393
|
+
);
|
|
1394
|
+
const type = attribute.type
|
|
1395
|
+
? `<text data-detail="context" x="${entity.x + entity.width - layout.padX}" y="${baseline}" class="er-type" font-size="${layout.typeFont}" text-anchor="end">${esc(attribute.type)}</text>`
|
|
1396
|
+
: '';
|
|
1397
|
+
const zebra = index % 2
|
|
1398
|
+
? `<rect x="${entity.x + 4}" y="${rowTop + 1}" width="${entity.width - 8}" height="${metrics.rowH - 2}" rx="4" class="er-zebra"/>`
|
|
1399
|
+
: '';
|
|
1400
|
+
const primary = keyRoles(attribute).includes('pk');
|
|
1401
|
+
return ` <g data-detail="context" data-er-row="${index}">
|
|
1402
|
+
<line x1="${entity.x}" y1="${rowTop}" x2="${entity.x + entity.width}" y2="${rowTop}" class="c-grid er-row-rule" stroke-width="0.5"/>
|
|
1403
|
+
${zebra}
|
|
1404
|
+
${keyGlyph}
|
|
1405
|
+
<text data-detail="context" x="${nameX}" y="${baseline}" class="t-primary${primary ? ' er-name-pk' : ''}" font-size="${nameFontSize}">${esc(attribute.name)}</text>
|
|
1406
|
+
${type}
|
|
1407
|
+
</g>`.replace(/[ \t]+$/gm, '');
|
|
1408
|
+
}).join('\n');
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function renderEntity(entity) {
|
|
1412
|
+
const header = entity.y + metrics.headerH;
|
|
1413
|
+
const { labelFont, labelY, labelX } = entityHeader(entity);
|
|
1414
|
+
const passport = {
|
|
1415
|
+
kind: 'database',
|
|
1416
|
+
sublabel: entity.sublabel,
|
|
1417
|
+
tag: entity.tag,
|
|
1418
|
+
context: i18nText(er.meta.locale, 'node.context.erd'),
|
|
1419
|
+
};
|
|
1420
|
+
const rows = renderEntityColumnRows(entity);
|
|
1421
|
+
const r = 8;
|
|
1422
|
+
return ` <g ${focusNodeAttrs(entity.id, entity.label, passport, er.meta.locale)} class="${hueClassOf(entity.tag)}">
|
|
1423
|
+
${focusNodeTitle(entity.label, passport)}
|
|
1424
|
+
<rect x="${entity.x}" y="${entity.y}" width="${entity.width}" height="${entity.height}" rx="${r}" class="c-mask"/>
|
|
1425
|
+
<rect x="${entity.x}" y="${entity.y}" width="${entity.width}" height="${entity.height}" rx="${r}" class="c-database er-table"${animateAttr(er.meta, 'node', entitySteps.get(entity.id))} stroke-width="1.2"/>
|
|
1426
|
+
<path class="er-table-header" d="M ${entity.x + r} ${entity.y} H ${entity.x + entity.width - r} A ${r} ${r} 0 0 1 ${entity.x + entity.width} ${entity.y + r} V ${header} H ${entity.x} V ${entity.y + r} A ${r} ${r} 0 0 1 ${entity.x + r} ${entity.y} Z"/>
|
|
1427
|
+
<path class="er-table-cap" d="M ${entity.x + 12} ${entity.y + 1.4} H ${entity.x + entity.width - 12}" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
|
1428
|
+
${renderSemanticSigil('database', { x: entity.x + 8, y: entity.y + 7, size: 14 })}
|
|
1429
|
+
<text data-node-label="" x="${labelX}" y="${entity.y + labelY}" class="t-primary er-title" font-size="${labelFont}" font-weight="700" text-anchor="middle">${esc(entity.label)}</text>
|
|
1430
|
+
<line x1="${entity.x}" y1="${header}" x2="${entity.x + entity.width}" y2="${header}" class="c-grid er-header-rule" stroke-width="1"/>
|
|
1431
|
+
${rows}
|
|
1432
|
+
</g>`;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function renderRelationshipPath(relationship, index) {
|
|
1436
|
+
const [cls] = arrowClassMap[markerStyleOf(relationship)] || arrowClassMap.default;
|
|
1437
|
+
const routed = pathFor(relationship);
|
|
1438
|
+
const d = bundledRelationshipD(routed, relationshipTrunkRuns(relationship));
|
|
1439
|
+
const strokeWidth = relationshipStrokeWidth(relationship);
|
|
1440
|
+
return ` <path ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-er-ends="${relationshipEnds(relationship).join(' ')}" data-composition-points="${routePointsValue(routed.points)}" d="${d}" class="${cls}"${animateAttr(er.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-start="url(#${cardinalityMarkerId(relationship, 'from')})" marker-end="url(#${cardinalityMarkerId(relationship, 'to')})"/>`;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function renderRelationshipLabel(relationship, index) {
|
|
1444
|
+
if (!relationship.label) return '';
|
|
1445
|
+
const [lx, ly] = erLabelPoint(relationship);
|
|
1446
|
+
const box = relationshipLabelBox(relationship);
|
|
1447
|
+
return ` <g data-detail="context" ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-er-ends="${relationshipEnds(relationship).join(' ')}">
|
|
1448
|
+
<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3" class="c-mask"/>
|
|
1449
|
+
<text x="${lx}" y="${ly}" class="${variantAccent(relationship.variant)}" font-size="${RELATIONSHIP_LABEL_FONT}" text-anchor="middle">${esc(relationship.label)}</text>
|
|
1450
|
+
</g>`;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// A key swatch is the badge a field row draws. A relationship swatch is a short
|
|
1454
|
+
// route meeting a table edge, carrying the marker's own glyph, ink, and weight,
|
|
1455
|
+
// so the legend shows the symbol in the context the diagram draws it.
|
|
1456
|
+
function legendSwatch(entry) {
|
|
1457
|
+
const x = entry.x + entry.lead;
|
|
1458
|
+
const centerY = entry.baseline - 4;
|
|
1459
|
+
const hit = `<rect class="er-legend-hit" tabindex="0" role="img" aria-label="${esc(entry.label)}" x="${x - 6}" y="${entry.baseline - 15}" width="${entry.width - entry.lead + 12}" height="22" rx="11"/>`;
|
|
1460
|
+
const divider = entry.lead && entry.x > layout.margin
|
|
1461
|
+
? `<line x1="${entry.x - 4}" y1="${centerY - 8}" x2="${entry.x - 4}" y2="${centerY + 8}" class="er-legend-divider" stroke-width="1"/>`
|
|
1462
|
+
: '';
|
|
1463
|
+
if (KEY_KINDS.has(entry.kind)) {
|
|
1464
|
+
return `${hit}<rect x="${x}" y="${entry.baseline - 10}" width="${LEGEND_KEY_SWATCH}" height="13" rx="3" class="er-key-badge" data-er-badge="${entry.kind}"/><text x="${x + LEGEND_KEY_SWATCH / 2}" y="${entry.baseline}" class="${KEY_ACCENT[entry.kind]}" text-anchor="middle" font-size="${layout.keyFont}" font-weight="600">${esc(entry.kind.toUpperCase())}</text>`;
|
|
1465
|
+
}
|
|
1466
|
+
const edgeX = x + LEGEND_END_SWATCH - 2;
|
|
1467
|
+
const glyph = cardinalityGlyphParts({
|
|
1468
|
+
cardinality: entry.kind === 'optional' ? null : entry.kind,
|
|
1469
|
+
optional: entry.kind === 'optional',
|
|
1470
|
+
});
|
|
1471
|
+
return `${divider}${hit}<line x1="${x}" y1="${centerY}" x2="${edgeX}" y2="${centerY}" class="er-legend-route" stroke-width="1.5"/><line x1="${edgeX}" y1="${centerY - 7}" x2="${edgeX}" y2="${centerY + 7}" class="er-legend-edge" stroke-width="2"/><g class="er-legend-glyph" transform="translate(${edgeX - MARKER_TOE_X} ${centerY - 8})" style="stroke: var(--text-muted)" stroke-width="1.75" fill="none">${glyph.join('')}</g>`;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// The ring a highlighted badge takes, in the ink of the role's own text.
|
|
1475
|
+
const KEY_RING = { pk: 'var(--database-stroke)', fk: 'var(--messagebus-stroke)', uk: 'var(--text-muted)' };
|
|
1476
|
+
|
|
1477
|
+
const LEGEND_HOVER_RULES = [
|
|
1478
|
+
...Object.entries(KEY_RING).map(([kind, ring]) => ` svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-er-row]:not(:has([data-er-badge="${kind}"])) { opacity: .22; }
|
|
1479
|
+
svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-er-row] [data-er-badge="${kind}"] { stroke: ${ring}; stroke-width: 1.25; }`),
|
|
1480
|
+
// Most schemas carry one and many on every route, so dimming alone would show
|
|
1481
|
+
// nothing: the matching glyph itself takes the accent ink wherever it is drawn.
|
|
1482
|
+
...Object.entries({ one: '[id^="er-one"]', many: '[id^="er-many"]', optional: '[id*="-optional"]' }).map(([kind, marker]) => ` svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-er-ends]:not([data-er-ends~="${kind}"]) { opacity: .12; }
|
|
1483
|
+
svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) marker${marker} { stroke: var(--database-stroke) !important; stroke-width: 2.25; }`),
|
|
1484
|
+
].join('\n');
|
|
1485
|
+
|
|
1486
|
+
function renderLegend() {
|
|
1487
|
+
const relationshipObstacles = relationshipLegendObstacles(relationships, {
|
|
1488
|
+
pointsFor: (relationship) => (renderableRelationship(relationship) ? pathFor(relationship).points : []),
|
|
1489
|
+
labelRectFor: (relationship) => (
|
|
1490
|
+
relationship.label && renderableRelationship(relationship)
|
|
1491
|
+
? relationshipLabelBox(relationship)
|
|
1492
|
+
: null
|
|
1493
|
+
),
|
|
1494
|
+
});
|
|
1495
|
+
return renderResolvedLegend({
|
|
1496
|
+
entries: erLegendEntries,
|
|
1497
|
+
locale: er.meta.locale,
|
|
1498
|
+
layout: legendLayout(viewBox[0], viewBox[1], { obstacles: relationshipObstacles }),
|
|
1499
|
+
renderSwatch: legendSwatch,
|
|
1500
|
+
labelClass: 't-primary',
|
|
1501
|
+
labelWeight: 600,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function renderSvg() {
|
|
1506
|
+
// A schema with more tables than one screen holds is a normal ERD, and the
|
|
1507
|
+
// reader asked for readable fields over a squeezed canvas: an automatic
|
|
1508
|
+
// canvas declares its height as intrinsic so the desktop reader scrolls
|
|
1509
|
+
// instead of shrinking the tables. An authored meta.viewBox stays
|
|
1510
|
+
// authoritative and keeps the established Viewer contract.
|
|
1511
|
+
const readerFit = er.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
|
|
1512
|
+
const readerMinimumText = er.meta?.viewBox ? '' : ' data-reader-min-text="7.5"';
|
|
1513
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(er.meta)} data-erd-ui="cards"${readerFit}${readerMinimumText}>
|
|
1514
|
+
${svgAccessibleText(er.meta, 'erd')}
|
|
1515
|
+
${renderDefinitions(renderCardinalityDefs())}
|
|
1516
|
+
<style>
|
|
1517
|
+
${DOMAIN_HUES.map((hue) => ` svg[data-erd-ui="cards"] .er-hue-${hue} { --er-hue: var(--${hue}-stroke); --er-wash: var(--${hue}-fill); }`).join('\n')}
|
|
1518
|
+
svg[data-erd-ui="cards"] .er-band { fill: color-mix(in srgb, var(--er-hue) 4%, transparent); stroke: color-mix(in srgb, var(--er-hue) 20%, transparent); stroke-dasharray: none; }
|
|
1519
|
+
svg[data-erd-ui="cards"] .er-band-pill { fill: var(--er-wash); stroke: color-mix(in srgb, var(--er-hue) 30%, transparent); stroke-width: .8; }
|
|
1520
|
+
svg[data-erd-ui="cards"] .er-band-label { fill: var(--er-hue); letter-spacing: .08em; text-transform: uppercase; }
|
|
1521
|
+
svg[data-erd-ui="cards"] .er-table { fill: var(--mask); stroke: color-mix(in srgb, var(--er-hue) 34%, var(--lane-stroke)); }
|
|
1522
|
+
svg[data-erd-ui="cards"] .er-table-header { fill: var(--er-wash); stroke: none; }
|
|
1523
|
+
svg[data-erd-ui="cards"] .er-table-cap { stroke: var(--er-hue); pointer-events: none; }
|
|
1524
|
+
svg[data-erd-ui="cards"] .er-header-rule { stroke: color-mix(in srgb, var(--er-hue) 30%, var(--lane-stroke)); }
|
|
1525
|
+
svg[data-erd-ui="cards"] [class*="er-hue-"] .semantic-sigil { color: var(--er-hue); }
|
|
1526
|
+
svg[data-erd-ui="cards"] .er-row-rule { stroke-opacity: 0; }
|
|
1527
|
+
svg[data-erd-ui="cards"] .er-zebra { fill: color-mix(in srgb, var(--er-hue) 6%, transparent); }
|
|
1528
|
+
svg[data-erd-ui="cards"] .er-name-pk { font-weight: 700; }
|
|
1529
|
+
svg[data-erd-ui="cards"] .er-type { fill: var(--text-muted); opacity: .86; }
|
|
1530
|
+
svg[data-erd-ui="cards"] .er-key-badge { fill: var(--database-fill); stroke: none; }
|
|
1531
|
+
svg[data-erd-ui="cards"] .er-key-badge[data-er-badge="fk"] { fill: var(--messagebus-fill); }
|
|
1532
|
+
svg[data-erd-ui="cards"] .er-key-badge[data-er-badge="uk"] { fill: none; stroke: var(--lane-stroke); }
|
|
1533
|
+
svg[data-erd-ui="cards"] .er-key-badge + text { text-anchor: middle; }
|
|
1534
|
+
svg[data-erd-ui="cards"] [data-legend] text.t-primary { fill: var(--text); }
|
|
1535
|
+
svg[data-erd-ui="cards"] [data-legend] > text.t-primary { fill: var(--text-muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
|
|
1536
|
+
svg[data-erd-ui="cards"] .er-legend-hit { fill: transparent; transition: fill .15s; }
|
|
1537
|
+
svg[data-erd-ui="cards"] .er-legend-hit:focus { outline: none; }
|
|
1538
|
+
svg[data-erd-ui="cards"] .er-legend-hit:focus-visible { stroke: var(--database-stroke); stroke-width: 1.5; }
|
|
1539
|
+
svg[data-erd-ui="cards"] [data-legend-semantic-kind]:is(:hover, :focus-within) .er-legend-hit { fill: var(--database-fill); }
|
|
1540
|
+
svg[data-erd-ui="cards"] [data-legend-semantic-kind]:is(:hover, :focus-within) .er-legend-glyph { stroke: var(--database-stroke) !important; }
|
|
1541
|
+
svg[data-erd-ui="cards"] .er-legend-route { stroke: var(--text-muted); stroke-opacity: .55; }
|
|
1542
|
+
svg[data-erd-ui="cards"] .er-legend-edge { stroke: var(--lane-stroke); stroke-linecap: round; }
|
|
1543
|
+
svg[data-erd-ui="cards"] .er-legend-divider { stroke: var(--lane-stroke); }
|
|
1544
|
+
svg[data-erd-ui="cards"] [data-er-row], svg[data-erd-ui="cards"] [data-er-ends] { transition: opacity .15s; }
|
|
1545
|
+
${LEGEND_HOVER_RULES}
|
|
1546
|
+
@media (prefers-reduced-motion: reduce) { svg[data-erd-ui="cards"] * { transition: none !important; } }
|
|
1547
|
+
</style>
|
|
1548
|
+
|
|
1549
|
+
<!-- Background Grid -->
|
|
1550
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
1551
|
+
|
|
1552
|
+
<!-- Domain bands (behind the routes so a channel is never washed out) -->
|
|
1553
|
+
${renderDomainBands()}
|
|
1554
|
+
|
|
1555
|
+
<!-- Bundled relationship trunks (under the branches: a bus runs close
|
|
1556
|
+
to the shared side, so a cardinality glyph that reaches further out
|
|
1557
|
+
has to stay readable over it, and the ring's mask has to be able to
|
|
1558
|
+
punch through) -->
|
|
1559
|
+
${bundledTrunkPaths()}
|
|
1560
|
+
|
|
1561
|
+
<!-- Relationship paths (before entities for correct z-order) -->
|
|
1562
|
+
${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipPath(relationship, index) : '')).filter(Boolean).join('\n')}
|
|
1563
|
+
|
|
1564
|
+
<!-- Entities -->
|
|
1565
|
+
${[...entities.values()].map(renderEntity).join('\n\n')}
|
|
1566
|
+
|
|
1567
|
+
<!-- Relationship labels -->
|
|
1568
|
+
${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipLabel(relationship, index) : '')).filter(Boolean).join('\n')}
|
|
1569
|
+
|
|
1570
|
+
<!-- Legend -->
|
|
1571
|
+
${renderLegend()}
|
|
1572
|
+
</svg>`;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
validateEr();
|
|
1576
|
+
if (layoutJsonMode) {
|
|
1577
|
+
console.log(JSON.stringify(buildLayoutReport(), null, 2));
|
|
1578
|
+
process.exit(0);
|
|
1579
|
+
}
|
|
1580
|
+
writeDiagram({
|
|
1581
|
+
outPath,
|
|
1582
|
+
template,
|
|
1583
|
+
diagramType: 'erd',
|
|
1584
|
+
meta: er.meta,
|
|
1585
|
+
svg: renderSvg(),
|
|
1586
|
+
cards: er.cards,
|
|
1587
|
+
sourceEvidence,
|
|
1588
|
+
});
|