@stacksjs/defaults 0.75.361 → 0.75.366
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 +5 -5
- package/ai/skills/stacks-payments/SKILL.md +10 -0
- 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/ai/skills/stacks-testing/SKILL.md +8 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,556 +1,1128 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
|
3
|
+
import { esc, renderDefinitions, renderSemanticSigil, textUnits, SEMANTIC_SIGIL_FOOTPRINT, SOURCE_BADGE_FOOTPRINT } from '../shared/utils.mjs';
|
|
4
4
|
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
|
5
5
|
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
6
|
-
import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
|
7
|
-
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
|
6
|
+
import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
|
7
|
+
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout, nodeTextFit } from '../shared/text-fit.mjs';
|
|
8
8
|
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
|
9
9
|
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
cleanAmbiguousCorridorProblems,
|
|
18
|
-
cleanBorderRunProblems,
|
|
19
|
-
cleanRouteRhythmProblems,
|
|
20
|
-
cleanLabelRouteClearanceProblems,
|
|
21
|
-
suggestLabelObstacleFix,
|
|
22
|
-
suggestLabelPairFix,
|
|
23
|
-
anchor,
|
|
24
|
-
automaticPortSpread,
|
|
25
|
-
defaultFromSide,
|
|
26
|
-
defaultToSide,
|
|
27
|
-
chosenSide,
|
|
28
|
-
roundedPath,
|
|
29
|
-
routePointsValue,
|
|
30
|
-
labelPoint,
|
|
31
|
-
arrowClassMap,
|
|
32
|
-
variantAccent
|
|
33
|
-
} from '../shared/geometry.mjs';
|
|
34
|
-
|
|
35
|
-
const stateTextFit = {
|
|
36
|
-
sublabelPreferred: 7,
|
|
37
|
-
sublabelMinimum: 6,
|
|
38
|
-
tagPreferred: 7,
|
|
39
|
-
tagMinimum: 6,
|
|
40
|
-
};
|
|
10
|
+
import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX } from '../shared/desktop-readability.mjs';
|
|
11
|
+
import { asArray, rectsOverlap, segmentRectClearance, roundedPath, routePointsValue, arrowClassMap, edgeLabelAccent } from '../shared/geometry.mjs';
|
|
12
|
+
|
|
13
|
+
// Lifecycle v3 draws one shape: the main path as a left-to-right row, loops
|
|
14
|
+
// back to earlier phases as arcs above it, and every other state (exits,
|
|
15
|
+
// waiting or recovery states) in one row below the phase it branches from.
|
|
16
|
+
// Geometry is derived from that structure alone; there are no routing controls.
|
|
41
17
|
|
|
42
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
-
const { diagram: lifecycle, template, outPath } = await loadDiagramWithBrandMarks({
|
|
19
|
+
const { diagram: lifecycle, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
|
44
20
|
rendererDir: __dirname,
|
|
45
21
|
diagramType: 'lifecycle',
|
|
46
|
-
defaultExample: 'agent-run.lifecycle.json'
|
|
22
|
+
defaultExample: 'agent-run.lifecycle.json',
|
|
47
23
|
});
|
|
48
24
|
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
25
|
+
const TEXT = { labelPreferred: 12, labelMinimum: 10, sublabelPreferred: 9, sublabelMinimum: 8, tagPreferred: 8, tagMinimum: 7, step: 8 };
|
|
26
|
+
const LABEL_FONT = 10;
|
|
27
|
+
const NOTE_FONT = 9;
|
|
28
|
+
const STATE_H = 64;
|
|
29
|
+
const STATE_MIN_W = 140;
|
|
30
|
+
const STATE_MAX_W = 220;
|
|
31
|
+
const MARGIN_X = 56;
|
|
32
|
+
const MARGIN_TOP = 28;
|
|
33
|
+
const PORT_PAD = 22;
|
|
34
|
+
const PORT_STEP = 26;
|
|
35
|
+
const CORNER = 12;
|
|
36
|
+
const FRAME_PAD = 10;
|
|
37
|
+
const ARC_BASE = 30;
|
|
38
|
+
const ARC_STEP = 24;
|
|
39
|
+
const TRACK_STEP = 18;
|
|
40
|
+
const LABEL_GAP = 6;
|
|
41
|
+
const CLEARANCE = 5;
|
|
42
|
+
const SPACING_ROUNDS = 5;
|
|
64
43
|
|
|
65
44
|
const typeClass = {
|
|
66
|
-
start: 'c-frontend',
|
|
67
|
-
|
|
68
|
-
waiting: 'c-cloud',
|
|
69
|
-
decision: 'c-security',
|
|
70
|
-
success: 'c-database',
|
|
71
|
-
failure: 'c-security',
|
|
72
|
-
neutral: 'c-external',
|
|
73
|
-
external: 'c-external'
|
|
45
|
+
start: 'c-frontend', active: 'c-frontend', waiting: 'c-cloud', decision: 'c-database',
|
|
46
|
+
success: 'c-backend', failure: 'c-security', neutral: 'c-external', external: 'c-external',
|
|
74
47
|
};
|
|
75
|
-
|
|
48
|
+
// Ordinary states carry no default corner sigil, so the ones a reader should
|
|
49
|
+
// notice (waiting, decision, outcomes, external) stand out. An ordinary state
|
|
50
|
+
// that ends the lifecycle is still an outcome and gets a stop sigil.
|
|
51
|
+
const QUIET_TYPES = new Set(['start', 'active', 'neutral']);
|
|
76
52
|
const textClass = {
|
|
77
|
-
start: 't-frontend',
|
|
78
|
-
|
|
79
|
-
waiting: 't-cloud',
|
|
80
|
-
decision: 't-security',
|
|
81
|
-
success: 't-database',
|
|
82
|
-
failure: 't-security',
|
|
83
|
-
neutral: 't-muted',
|
|
84
|
-
external: 't-muted'
|
|
53
|
+
start: 't-frontend', active: 't-frontend', waiting: 't-cloud', decision: 't-database',
|
|
54
|
+
success: 't-backend', failure: 't-security', neutral: 't-muted', external: 't-muted',
|
|
85
55
|
};
|
|
86
56
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
|
|
99
|
-
// "terminal" maps to the bottom outcome band, and every other lane shares the
|
|
100
|
-
// middle event band (separated visually via yOffset).
|
|
101
|
-
function bandFor(lane) {
|
|
102
|
-
if (lane === 'main') return 'phase';
|
|
103
|
-
if (lane === 'terminal') return 'outcome';
|
|
104
|
-
return 'event';
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function measureState(state) {
|
|
108
|
-
const isPhase = bandFor(state.lane) === 'phase';
|
|
109
|
-
const isOutcome = bandFor(state.lane) === 'outcome';
|
|
110
|
-
const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
|
|
111
|
-
const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
|
|
112
|
-
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
|
|
113
|
-
const cx = xs[state.col] ?? xs[xs.length - 1];
|
|
114
|
-
const y = (
|
|
115
|
-
isPhase ? layout.phaseY :
|
|
116
|
-
isOutcome ? layout.outcomeY :
|
|
117
|
-
layout.eventY
|
|
118
|
-
) + (state.yOffset || 0);
|
|
57
|
+
const transitions = asArray(lifecycle.transitions);
|
|
58
|
+
const mainPath = asArray(lifecycle.mainPath);
|
|
59
|
+
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, { ...state }]));
|
|
60
|
+
const spineIndex = new Map(mainPath.map((id, index) => [id, index]));
|
|
61
|
+
const onSpine = (id) => spineIndex.has(id);
|
|
62
|
+
|
|
63
|
+
validateStructure();
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- measure
|
|
66
|
+
|
|
67
|
+
function fonts(state, width) {
|
|
119
68
|
return {
|
|
120
|
-
|
|
121
|
-
width,
|
|
122
|
-
|
|
123
|
-
x: cx - width / 2,
|
|
124
|
-
y,
|
|
125
|
-
cx,
|
|
126
|
-
cy: y + height / 2
|
|
69
|
+
label: fittedNodeFontSize(state.label, brandLabelFitWidth(state, width), TEXT.labelPreferred, TEXT.labelMinimum),
|
|
70
|
+
sublabel: fittedNodeFontSize(state.sublabel, width, TEXT.sublabelPreferred, TEXT.sublabelMinimum),
|
|
71
|
+
tag: fittedNodeFontSize(state.tag, width, TEXT.tagPreferred, TEXT.tagMinimum),
|
|
127
72
|
};
|
|
128
73
|
}
|
|
129
74
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
75
|
+
// Wide enough for every row at its preferred size; the label also clears the
|
|
76
|
+
// top-rail decorations (sigil or step, brand, source badge) that share its row.
|
|
77
|
+
function preferredWidth(state) {
|
|
78
|
+
const need = (text, font) => (text ? textUnits(text) * font * nodeTextFit.widthFactor + nodeTextFit.horizontalPadding + 36 : 0);
|
|
79
|
+
const left = Math.max(SEMANTIC_SIGIL_FOOTPRINT + 2, state.step ? 26 + minimumNodeTextWidth(state.step, TEXT.step) : 0);
|
|
80
|
+
const right = (brandMarkFor(state) ? 26 : 4) + (sourceEvidence?.nodes?.[state.id]?.length ? SOURCE_BADGE_FOOTPRINT : 0);
|
|
81
|
+
const label = minimumNodeTextWidth(state.label, TEXT.labelPreferred) + left + right + 4;
|
|
82
|
+
return Math.ceil(Math.min(STATE_MAX_W, Math.max(STATE_MIN_W,
|
|
83
|
+
label, need(state.sublabel, TEXT.sublabelPreferred), need(state.tag, TEXT.tagPreferred))));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const state of states.values()) {
|
|
87
|
+
state.width = preferredWidth(state);
|
|
88
|
+
state.height = STATE_H;
|
|
136
89
|
}
|
|
137
|
-
|
|
138
|
-
|
|
90
|
+
const smallestText = Math.min(LABEL_FONT, ...[...states.values()].flatMap((state) => {
|
|
91
|
+
const fitted = fonts(state, state.width);
|
|
92
|
+
return [fitted.label, state.sublabel ? fitted.sublabel : Infinity];
|
|
93
|
+
}));
|
|
94
|
+
const readableWidthBudget = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * smallestText / MIN_PROJECTED_NODE_TEXT_PX);
|
|
95
|
+
|
|
96
|
+
function labelBox(transition) {
|
|
97
|
+
const text = transition.label || '';
|
|
98
|
+
const note = transition.note || '';
|
|
99
|
+
const width = Math.ceil(Math.max(
|
|
100
|
+
text ? textUnits(text) * LABEL_FONT * 0.6 : 0,
|
|
101
|
+
note ? textUnits(note) * NOTE_FONT * 0.6 : 0,
|
|
102
|
+
) + 12);
|
|
103
|
+
return { width, height: text && note ? 28 : 16 };
|
|
139
104
|
}
|
|
105
|
+
const hasLabel = (transition) => Boolean(transition.label || transition.note);
|
|
140
106
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
107
|
+
// ---------------------------------------------------------------- classify
|
|
108
|
+
|
|
109
|
+
// The first forward transition between consecutive main-path states is the
|
|
110
|
+
// row itself; any other main-path pair is an arc above the row.
|
|
111
|
+
const spineEdges = new Map();
|
|
112
|
+
for (const transition of transitions) {
|
|
113
|
+
const from = spineIndex.get(transition.from);
|
|
114
|
+
const to = spineIndex.get(transition.to);
|
|
115
|
+
if (from !== undefined && to === from + 1 && !spineEdges.has(from)) spineEdges.set(from, transition);
|
|
116
|
+
}
|
|
117
|
+
const mainPathStateWidth = mainPath.reduce((sum, id) => sum + states.get(id).width, 0);
|
|
118
|
+
const minimumSpineGaps = mainPath.slice(0, -1).map((_, index) => {
|
|
119
|
+
const edge = spineEdges.get(index);
|
|
120
|
+
return (edge && hasLabel(edge) ? labelBox(edge).width : 0) + 26;
|
|
121
|
+
});
|
|
144
122
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
123
|
+
// Every other state sits in a row below the main path at its distance from
|
|
124
|
+
// it, so each transition stays within one row or joins two adjacent rows.
|
|
125
|
+
const depth = new Map(mainPath.map((id) => [id, 0]));
|
|
126
|
+
for (let frontier = [...mainPath]; frontier.length;) {
|
|
127
|
+
const next = [];
|
|
128
|
+
for (const id of frontier) {
|
|
129
|
+
for (const transition of transitions) {
|
|
130
|
+
const other = transition.from === id ? transition.to : transition.to === id ? transition.from : null;
|
|
131
|
+
if (other && states.has(other) && !depth.has(other)) {
|
|
132
|
+
depth.set(other, depth.get(id) + 1);
|
|
133
|
+
next.push(other);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
149
136
|
}
|
|
137
|
+
frontier = next;
|
|
138
|
+
}
|
|
139
|
+
for (const id of states.keys()) if (!depth.has(id)) depth.set(id, 1);
|
|
140
|
+
const depthOf = (id) => depth.get(id);
|
|
141
|
+
const rowCount = Math.max(...depth.values()) + 1;
|
|
142
|
+
const rows = Array.from({ length: rowCount }, (_, row) => (row === 0
|
|
143
|
+
? mainPath.map((id) => states.get(id))
|
|
144
|
+
: [...states.values()].filter((state) => depthOf(state.id) === row)));
|
|
145
|
+
const offStates = rows.slice(1).flat();
|
|
146
|
+
|
|
147
|
+
const kindOf = new Map(transitions.map((transition) => {
|
|
148
|
+
const [from, to] = [depthOf(transition.from), depthOf(transition.to)];
|
|
149
|
+
if (from === 0 && to === 0) return [transition, spineEdges.get(spineIndex.get(transition.from)) === transition ? 'spine' : 'arc'];
|
|
150
|
+
return [transition, from === to ? 'same' : 'between'];
|
|
151
|
+
}));
|
|
152
|
+
const ofKind = (kind) => transitions.filter((transition) => kindOf.get(transition) === kind);
|
|
150
153
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
// Exits that leave several main-path phases for one state are drawn once:
|
|
155
|
+
// from a composite frame around consecutive phases, or from a shared bus.
|
|
156
|
+
const downByTarget = new Map();
|
|
157
|
+
for (const transition of ofKind('between')) {
|
|
158
|
+
if (depthOf(transition.from) !== 0) continue;
|
|
159
|
+
if (!downByTarget.has(transition.to)) downByTarget.set(transition.to, []);
|
|
160
|
+
downByTarget.get(transition.to).push(transition);
|
|
161
|
+
}
|
|
162
|
+
// A merged exit promises one meaning. Only a target with one eligible
|
|
163
|
+
// complete-signature bucket joins a bracket; all other exits stay singles.
|
|
164
|
+
const exitSignature = (transition) => [transition.label ?? '', transition.note ?? '', variantOf(transition)].join('\u0000');
|
|
165
|
+
const brackets = new Map();
|
|
166
|
+
for (const [target, list] of downByTarget) {
|
|
167
|
+
const buckets = new Map();
|
|
168
|
+
for (const transition of list) {
|
|
169
|
+
const signature = exitSignature(transition);
|
|
170
|
+
if (!buckets.has(signature)) buckets.set(signature, []);
|
|
171
|
+
buckets.get(signature).push(transition);
|
|
155
172
|
}
|
|
173
|
+
const eligible = [...buckets.values()].filter((bucket) => new Set(bucket.map((transition) => transition.from)).size >= 2);
|
|
174
|
+
if (eligible.length !== 1) continue;
|
|
175
|
+
const [group] = eligible;
|
|
176
|
+
const sources = [...new Set(group.map((transition) => transition.from))].sort((a, b) => spineIndex.get(a) - spineIndex.get(b));
|
|
177
|
+
const key = sources.join('\u0000');
|
|
178
|
+
if (!brackets.has(key)) brackets.set(key, { key, sources, targets: [], transitions: [] });
|
|
179
|
+
brackets.get(key).targets.push(target);
|
|
180
|
+
brackets.get(key).transitions.push(...group);
|
|
181
|
+
}
|
|
182
|
+
const bracketOf = new Map();
|
|
183
|
+
for (const bracket of brackets.values()) {
|
|
184
|
+
for (const transition of bracket.transitions) bracketOf.set(transition, bracket);
|
|
185
|
+
}
|
|
186
|
+
// Consecutive main-path phases form a composite state: one frame around them
|
|
187
|
+
// and one exit per target, like a UML superstate transition. Frames never
|
|
188
|
+
// partially overlap; such a bracket keeps a shared bus under its phases.
|
|
189
|
+
const frameSpans = [];
|
|
190
|
+
for (const bracket of brackets.values()) {
|
|
191
|
+
const indexes = bracket.sources.map((id) => spineIndex.get(id));
|
|
192
|
+
const lo = Math.min(...indexes);
|
|
193
|
+
const hi = Math.max(...indexes);
|
|
194
|
+
if (hi - lo + 1 !== indexes.length || frameSpans.some((span) => span.lo <= hi && lo <= span.hi)) continue;
|
|
195
|
+
bracket.frame = { lo, hi };
|
|
196
|
+
frameSpans.push(bracket.frame);
|
|
197
|
+
}
|
|
198
|
+
const bracketTargets = new Map();
|
|
199
|
+
for (const bracket of brackets.values()) for (const target of bracket.targets) bracketTargets.set(target, bracket);
|
|
156
200
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
201
|
+
const outgoing = new Set(transitions.map((transition) => transition.from));
|
|
202
|
+
const isFinal = (state) => !outgoing.has(state.id);
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------- layout
|
|
205
|
+
|
|
206
|
+
// Each round tries ports pulled toward their partners and ports spread evenly,
|
|
207
|
+
// and keeps the placed layout with the fewest bends and short jogs.
|
|
208
|
+
let spacing = { spineGap: 72, rowGap: 44, drop: 58, firstTrack: 34 };
|
|
209
|
+
let geometry;
|
|
210
|
+
// Labels that could not be placed at tighter spacing and prompted retries.
|
|
211
|
+
const spacingWideners = new Set();
|
|
212
|
+
for (let round = 0; round < SPACING_ROUNDS; round += 1) {
|
|
213
|
+
const candidates = ['partner', 'even'].map((ports) => ({ ports, ...layout({ ...spacing, ports }) }));
|
|
214
|
+
const placed = candidates.filter((candidate) => !candidate.unplacedLabels.length);
|
|
215
|
+
const best = placed.sort((a, b) => routeCost(a.routes) - routeCost(b.routes))[0] || candidates[0];
|
|
216
|
+
// layout() positions the shared states, so the winner is laid out again.
|
|
217
|
+
geometry = best === candidates.at(-1) ? best : layout({ ...spacing, ports: best.ports });
|
|
218
|
+
if (placed.length) break;
|
|
219
|
+
if (round === 0) {
|
|
220
|
+
const closest = [...candidates].sort((a, b) => a.unplacedLabels.length - b.unplacedLabels.length)[0];
|
|
221
|
+
for (const transition of closest.unplacedLabels) spacingWideners.add(transition);
|
|
222
|
+
}
|
|
223
|
+
const nextSpineGap = spacing.spineGap + 28;
|
|
224
|
+
// The canvas includes this whole row. Widening beyond its text budget
|
|
225
|
+
// cannot succeed; keep its current gaps while other spacing still grows.
|
|
226
|
+
const nextMainPathWidth = MARGIN_X * 2 + mainPathStateWidth
|
|
227
|
+
+ minimumSpineGaps.reduce((sum, minimum) => sum + Math.max(nextSpineGap, minimum), 0);
|
|
228
|
+
spacing = { spineGap: nextMainPathWidth > readableWidthBudget ? spacing.spineGap : nextSpineGap,
|
|
229
|
+
rowGap: spacing.rowGap + 28, drop: spacing.drop + 14, firstTrack: spacing.firstTrack + 6 };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function routeCost(routes) {
|
|
233
|
+
let cost = 0;
|
|
234
|
+
for (const { points } of routes.values()) {
|
|
235
|
+
cost += Math.max(0, points.length - 2) * 10;
|
|
236
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
237
|
+
const run = Math.abs(points[index][0] - points[index - 1][0]);
|
|
238
|
+
if (points.length > 2 && Math.abs(points[index][1] - points[index - 1][1]) < 0.5) cost += run * 0.1 + (run < 30 ? 40 : 0);
|
|
161
239
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
240
|
+
}
|
|
241
|
+
return cost;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function layout({ spineGap, rowGap, drop, firstTrack, ports: portMode }) {
|
|
245
|
+
// Main-path x positions: each gap fits its row label.
|
|
246
|
+
let cursor = MARGIN_X;
|
|
247
|
+
mainPath.forEach((id, index) => {
|
|
248
|
+
const state = states.get(id);
|
|
249
|
+
state.x = cursor;
|
|
250
|
+
state.cx = cursor + state.width / 2;
|
|
251
|
+
cursor += state.width + Math.max(spineGap, minimumSpineGaps[index] ?? 26);
|
|
252
|
+
});
|
|
253
|
+
for (let row = 1; row < rowCount; row += 1) placeRow(row, rowGap);
|
|
254
|
+
|
|
255
|
+
// Shift horizontally so the leftmost element keeps the margin.
|
|
256
|
+
const minX = Math.min(...[...states.values()].map((state) => state.x));
|
|
257
|
+
const dx = MARGIN_X - minX;
|
|
258
|
+
for (const state of states.values()) { state.x += dx; state.cx += dx; }
|
|
259
|
+
|
|
260
|
+
const arcs = assignArcLevels();
|
|
261
|
+
const maxLevel = Math.max(0, ...arcs.map((arc) => arc.level));
|
|
262
|
+
const arcLabelReserve = arcs.some((arc) => hasLabel(arc.transition)) ? 12 : 0;
|
|
263
|
+
const spineTop = MARGIN_TOP + (maxLevel ? ARC_BASE + (maxLevel - 1) * ARC_STEP + arcLabelReserve : 0);
|
|
264
|
+
|
|
265
|
+
const ports = new Map();
|
|
266
|
+
const portOf = (id, side, key) => ports.get(`${id}|${side}`)?.get(key);
|
|
267
|
+
const requestPort = (id, side, key, otherX, order) => {
|
|
268
|
+
const slot = `${id}|${side}`;
|
|
269
|
+
if (!ports.has(slot)) ports.set(slot, new Map());
|
|
270
|
+
if (!ports.get(slot).has(key)) ports.get(slot).set(key, { key, otherX, order, x: 0 });
|
|
271
|
+
};
|
|
272
|
+
const cx = (id) => states.get(id).cx;
|
|
273
|
+
|
|
274
|
+
// Connectors cross the gap below row `gap`: single transitions between two
|
|
275
|
+
// rows, composite frame exits, shared buses, and loops between two
|
|
276
|
+
// non-adjacent states of one row.
|
|
277
|
+
const connectors = [];
|
|
278
|
+
for (const transition of ofKind('between')) {
|
|
279
|
+
if (bracketOf.has(transition)) continue;
|
|
280
|
+
const down = depthOf(transition.from) < depthOf(transition.to);
|
|
281
|
+
const upperId = down ? transition.from : transition.to;
|
|
282
|
+
const lowerId = down ? transition.to : transition.from;
|
|
283
|
+
connectors.push({ kind: 'single', transition, down, upperId, lowerId, gap: depthOf(upperId), order: transitions.indexOf(transition) });
|
|
284
|
+
}
|
|
285
|
+
const frames = [];
|
|
286
|
+
for (const bracket of brackets.values()) {
|
|
287
|
+
const order = transitions.indexOf(bracket.transitions[0]);
|
|
288
|
+
if (!bracket.frame) {
|
|
289
|
+
connectors.push({ kind: 'bracket', bracket, gap: 0, order });
|
|
170
290
|
continue;
|
|
171
291
|
}
|
|
172
|
-
|
|
173
|
-
|
|
292
|
+
const first = states.get(mainPath[bracket.frame.lo]);
|
|
293
|
+
const last = states.get(mainPath[bracket.frame.hi]);
|
|
294
|
+
const rect = { x: first.x - FRAME_PAD, y: spineTop - FRAME_PAD, width: last.x + last.width - first.x + FRAME_PAD * 2, height: STATE_H + FRAME_PAD * 2 };
|
|
295
|
+
frames.push(rect);
|
|
296
|
+
for (const target of bracket.targets) connectors.push({ kind: 'frame', bracket, rect, lowerId: target, gap: 0, order });
|
|
297
|
+
}
|
|
298
|
+
// Neighbours in one lower row connect side to side; others loop underneath.
|
|
299
|
+
const rowOrder = rows.map((list) => [...list].sort((a, b) => a.x - b.x).map((state) => state.id));
|
|
300
|
+
const adjacent = (transition) => transition.from !== transition.to
|
|
301
|
+
&& Math.abs(rowOrder[depthOf(transition.from)].indexOf(transition.from) - rowOrder[depthOf(transition.from)].indexOf(transition.to)) === 1;
|
|
302
|
+
const sides = ofKind('same').filter(adjacent);
|
|
303
|
+
// Side labels ride on the line for groups of three or more. Reserve their
|
|
304
|
+
// actual height before placing rows: horizontal gap growth cannot clear a
|
|
305
|
+
// label trapped between two full-width parallel strokes.
|
|
306
|
+
const sideGroups = new Map();
|
|
307
|
+
for (const transition of sides) {
|
|
308
|
+
const key = [transition.from, transition.to].sort().join('\u0000');
|
|
309
|
+
if (!sideGroups.has(key)) sideGroups.set(key, []);
|
|
310
|
+
sideGroups.get(key).push(transition);
|
|
311
|
+
}
|
|
312
|
+
const sideLanes = [];
|
|
313
|
+
const rowHeights = rows.map(() => STATE_H);
|
|
314
|
+
for (const group of sideGroups.values()) {
|
|
315
|
+
const rightward = (transition) => states.get(transition.from).x < states.get(transition.to).x;
|
|
316
|
+
const ordered = [...group.filter(rightward), ...group.filter((transition) => !rightward(transition))];
|
|
317
|
+
if (ordered.length > 5) {
|
|
318
|
+
const message = `${ordered.length} transitions between the neighbouring states "${ordered[0].from}" and "${ordered[0].to}" cannot spread apart on the shared edge; at most five stay legible.`;
|
|
319
|
+
throwDiagnosticProblems('Lifecycle validation failed', [message], {
|
|
320
|
+
subject: { diagramType: 'lifecycle' },
|
|
321
|
+
diagnostics: [structureProblem('lifecycle/crowded-side-transitions', message,
|
|
322
|
+
{ collection: 'transitions', from: ordered[0].from, to: ordered[0].to },
|
|
323
|
+
['merge the triggers into one transition label'])],
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
let step = Math.min(20, 48 / (ordered.length - 1));
|
|
327
|
+
if (ordered.length > 2) {
|
|
328
|
+
const heights = ordered.map((transition) => hasLabel(transition) ? labelBox(transition).height : 0);
|
|
329
|
+
step = Math.max(step, ...heights.map((height) => height ? height / 2 + CLEARANCE : 0),
|
|
330
|
+
...heights.slice(1).map((height, index) => height && heights[index] ? (height + heights[index]) / 2 + 4 : 0));
|
|
331
|
+
const row = depthOf(ordered[0].from);
|
|
332
|
+
// Keep every side port eight pixels inside the rounded state corners.
|
|
333
|
+
rowHeights[row] = Math.max(rowHeights[row], step * (ordered.length - 1) + 16);
|
|
334
|
+
}
|
|
335
|
+
sideLanes.push({ ordered, step });
|
|
336
|
+
}
|
|
337
|
+
for (const transition of ofKind('same').filter((transition) => !adjacent(transition))) {
|
|
338
|
+
connectors.push({ kind: 'loop', transition, gap: depthOf(transition.from), order: transitions.indexOf(transition) });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const connector of connectors) {
|
|
342
|
+
if (connector.kind === 'single') {
|
|
343
|
+
requestPort(connector.upperId, 'bottom', connector, cx(connector.lowerId), connector.order);
|
|
344
|
+
requestPort(connector.lowerId, 'top', connector, cx(connector.upperId), connector.order);
|
|
345
|
+
} else if (connector.kind === 'frame') {
|
|
346
|
+
requestPort(connector.lowerId, 'top', connector, connector.rect.x + connector.rect.width / 2, connector.order);
|
|
347
|
+
} else if (connector.kind === 'loop') {
|
|
348
|
+
const { from, to } = connector.transition;
|
|
349
|
+
if (from === to) {
|
|
350
|
+
requestPort(from, 'bottom', `${connector.order}:out`, Infinity, 0);
|
|
351
|
+
requestPort(from, 'bottom', `${connector.order}:in`, Infinity, 1);
|
|
352
|
+
} else {
|
|
353
|
+
requestPort(from, 'bottom', `${connector.order}:out`, cx(to), connector.order);
|
|
354
|
+
requestPort(to, 'bottom', `${connector.order}:in`, cx(from), connector.order);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
const { sources, targets } = connector.bracket;
|
|
358
|
+
const dropCenter = targets.reduce((sum, id) => sum + cx(id), 0) / targets.length;
|
|
359
|
+
const sourceCenter = sources.reduce((sum, id) => sum + cx(id), 0) / sources.length;
|
|
360
|
+
for (const id of sources) requestPort(id, 'bottom', connector, dropCenter, connector.order);
|
|
361
|
+
for (const id of targets) requestPort(id, 'top', connector, sourceCenter, connector.order);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const arc of arcs) {
|
|
365
|
+
const { transition } = arc;
|
|
366
|
+
if (transition.from === transition.to) {
|
|
367
|
+
requestPort(transition.from, 'top', `${arc.key}:out`, Infinity, 0);
|
|
368
|
+
requestPort(transition.from, 'top', `${arc.key}:in`, Infinity, 1);
|
|
369
|
+
} else {
|
|
370
|
+
requestPort(transition.from, 'top', `${arc.key}:out`, cx(transition.to), 0);
|
|
371
|
+
requestPort(transition.to, 'top', `${arc.key}:in`, cx(transition.from), 0);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
distributePorts(ports, arcs, portMode);
|
|
375
|
+
placeFrameExits(connectors, ports, portOf);
|
|
376
|
+
|
|
377
|
+
// Each gap assigns its horizontal runs to tracks, then the next row follows.
|
|
378
|
+
const runs = connectors.map((connector) => {
|
|
379
|
+
if (connector.kind === 'loop') {
|
|
380
|
+
const { from, to } = connector.transition;
|
|
381
|
+
const tops = [portOf(from, 'bottom', `${connector.order}:out`).x, portOf(to, 'bottom', `${connector.order}:in`).x];
|
|
382
|
+
return { connector, tops, bottoms: [], lo: Math.min(...tops), hi: Math.max(...tops), straight: false };
|
|
383
|
+
}
|
|
384
|
+
if (connector.kind === 'bracket') {
|
|
385
|
+
const { sources, targets } = connector.bracket;
|
|
386
|
+
const tops = sources.map((id) => portOf(id, 'bottom', connector).x);
|
|
387
|
+
const bottoms = targets.map((id) => portOf(id, 'top', connector).x);
|
|
388
|
+
const xs = [...tops, ...bottoms];
|
|
389
|
+
return { connector, tops, bottoms, lo: Math.min(...xs), hi: Math.max(...xs), straight: false };
|
|
390
|
+
}
|
|
391
|
+
const top = connector.kind === 'frame' ? connector.exitX : portOf(connector.upperId, 'bottom', connector).x;
|
|
392
|
+
const bottom = portOf(connector.lowerId, 'top', connector).x;
|
|
393
|
+
return { connector, tops: [top], bottoms: [bottom], lo: Math.min(top, bottom), hi: Math.max(top, bottom), straight: Math.abs(top - bottom) < 0.5 };
|
|
394
|
+
});
|
|
395
|
+
const rowTop = [spineTop];
|
|
396
|
+
const trackY = [];
|
|
397
|
+
for (let row = 0; row < rowCount; row += 1) {
|
|
398
|
+
for (const state of rows[row]) {
|
|
399
|
+
state.y = rowTop[row];
|
|
400
|
+
state.height = rowHeights[row];
|
|
401
|
+
state.cy = rowTop[row] + state.height / 2;
|
|
402
|
+
}
|
|
403
|
+
const bottom = rowTop[row] + rowHeights[row];
|
|
404
|
+
const count = assignTracks(runs.filter((run) => run.connector.gap === row));
|
|
405
|
+
trackY[row] = (track) => bottom + firstTrack + track * TRACK_STEP;
|
|
406
|
+
if (row + 1 < rowCount) rowTop.push((count ? trackY[row](count - 1) : bottom + firstTrack) + drop);
|
|
407
|
+
}
|
|
408
|
+
const rowBottom = (row) => rowTop[row] + rowHeights[row];
|
|
409
|
+
|
|
410
|
+
// Routes, keyed by transition.
|
|
411
|
+
const routes = new Map();
|
|
412
|
+
const junctions = [];
|
|
413
|
+
for (const [index, transition] of spineEdges) {
|
|
414
|
+
const from = states.get(mainPath[index]);
|
|
415
|
+
const to = states.get(mainPath[index + 1]);
|
|
416
|
+
routes.set(transition, { points: [[from.x + from.width, from.cy], [to.x, to.cy]], segment: 0, labelMode: 'above' });
|
|
417
|
+
}
|
|
418
|
+
for (const arc of arcs) {
|
|
419
|
+
const { transition } = arc;
|
|
420
|
+
const out = portOf(transition.from, 'top', `${arc.key}:out`).x;
|
|
421
|
+
const inn = portOf(transition.to, 'top', `${arc.key}:in`).x;
|
|
422
|
+
const y = spineTop - ARC_BASE - (arc.level - 1) * ARC_STEP;
|
|
423
|
+
routes.set(transition, { points: [[out, spineTop], [out, y], [inn, y], [inn, spineTop]], segment: 1, labelMode: 'on' });
|
|
424
|
+
}
|
|
425
|
+
for (const run of runs) {
|
|
426
|
+
const { connector } = run;
|
|
427
|
+
const { gap } = connector;
|
|
428
|
+
const y = trackY[gap](run.track ?? 0);
|
|
429
|
+
const upper = rowBottom(gap);
|
|
430
|
+
const lower = rowTop[gap + 1];
|
|
431
|
+
if (connector.kind === 'loop') {
|
|
432
|
+
const [a, b] = run.tops;
|
|
433
|
+
routes.set(connector.transition, { points: [[a, upper], [a, y], [b, y], [b, upper]], segment: 1, labelMode: 'on' });
|
|
174
434
|
continue;
|
|
175
435
|
}
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// handles the ordinary case, this rejects what it cannot rescue.
|
|
190
|
-
const availableTextW = availableNodeTextWidth(state.width);
|
|
191
|
-
for (const [field, value, minimum] of [
|
|
192
|
-
['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
|
|
193
|
-
['Tag', state.tag, stateTextFit.tagMinimum],
|
|
194
|
-
]) {
|
|
195
|
-
if (!value) continue;
|
|
196
|
-
const minimumW = minimumNodeTextWidth(value, minimum);
|
|
197
|
-
if (minimumW > availableTextW) {
|
|
198
|
-
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px - shorten the ${field.toLowerCase()} or increase state.width.`);
|
|
436
|
+
if (connector.kind === 'single' || connector.kind === 'frame') {
|
|
437
|
+
const [top] = run.tops;
|
|
438
|
+
const [bottom] = run.bottoms;
|
|
439
|
+
const start = connector.kind === 'frame' ? connector.rect.y + connector.rect.height : upper;
|
|
440
|
+
const down = run.straight ? [[top, start], [top, lower]] : [[top, start], [top, y], [bottom, y], [bottom, lower]];
|
|
441
|
+
if (connector.kind === 'single') {
|
|
442
|
+
const points = connector.down ? down : [...down].reverse();
|
|
443
|
+
routes.set(connector.transition, { points, segment: connector.down ? points.length - 2 : 0, labelMode: 'beside' });
|
|
444
|
+
} else {
|
|
445
|
+
connector.bracket.transitions.filter((transition) => transition.to === connector.lowerId)
|
|
446
|
+
.forEach((transition, index) => routes.set(transition, {
|
|
447
|
+
points: down, segment: down.length - 2, labelMode: index ? 'none' : 'beside', bracket: connector.bracket,
|
|
448
|
+
}));
|
|
199
449
|
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const { bracket } = connector;
|
|
453
|
+
const labelled = new Set();
|
|
454
|
+
for (const transition of bracket.transitions) {
|
|
455
|
+
const tick = portOf(transition.from, 'bottom', connector).x;
|
|
456
|
+
const dropX = portOf(transition.to, 'top', connector).x;
|
|
457
|
+
const points = Math.abs(tick - dropX) < 0.5
|
|
458
|
+
? [[tick, upper], [tick, lower]]
|
|
459
|
+
: [[tick, upper], [tick, y], [dropX, y], [dropX, lower]];
|
|
460
|
+
const owner = !labelled.has(transition.to);
|
|
461
|
+
labelled.add(transition.to);
|
|
462
|
+
routes.set(transition, { points, segment: points.length - 2, labelMode: owner ? 'beside' : 'none', bracket });
|
|
463
|
+
}
|
|
464
|
+
for (const x of [...run.tops, ...run.bottoms]) {
|
|
465
|
+
if (x > run.lo + 0.5 && x < run.hi - 0.5) junctions.push([x, y]);
|
|
200
466
|
}
|
|
201
467
|
}
|
|
468
|
+
// Parallel transitions between two neighbours spread symmetrically around
|
|
469
|
+
// the shared edge, left-to-right ones first; a reciprocal pair lands at ±10.
|
|
470
|
+
for (const { ordered, step } of sideLanes) {
|
|
471
|
+
ordered.forEach((transition, index) => {
|
|
472
|
+
const from = states.get(transition.from);
|
|
473
|
+
const to = states.get(transition.to);
|
|
474
|
+
const y = from.cy + (index - (ordered.length - 1) / 2) * step;
|
|
475
|
+
const points = from.x < to.x ? [[from.x + from.width, y], [to.x, y]] : [[from.x, y], [to.x + to.width, y]];
|
|
476
|
+
// With three or more parallels the inner strokes leave no lane for a
|
|
477
|
+
// floating label, so their labels ride on the line like arc labels.
|
|
478
|
+
routes.set(transition, { points, segment: 0, labelMode: ordered.length > 2 ? 'on' : 'above' });
|
|
479
|
+
});
|
|
480
|
+
}
|
|
202
481
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
482
|
+
const stateRight = Math.max(...[...states.values()].map((state) => state.x + state.width));
|
|
483
|
+
const placed = placeLabels({ routes, contentRight: stateRight });
|
|
484
|
+
const boxes = [...states.values(), ...placed.placedLabels.values()];
|
|
485
|
+
const contentBottom = Math.max(
|
|
486
|
+
...boxes.map((box) => box.y + box.height),
|
|
487
|
+
...[...routes.values()].flatMap((route) => route.points.map((point) => point[1])),
|
|
488
|
+
);
|
|
489
|
+
const contentRight = Math.max(...boxes.map((box) => box.x + box.width));
|
|
490
|
+
return { routes, junctions, frames, contentBottom, contentRight, ...placed };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// A composite exit leaves the frame directly above its target when possible,
|
|
494
|
+
// clear of the inner phases' own connectors and of sibling exits.
|
|
495
|
+
function placeFrameExits(connectors, ports, portOf) {
|
|
496
|
+
const frameConnectors = connectors.filter((connector) => connector.kind === 'frame');
|
|
497
|
+
for (const connector of frameConnectors) {
|
|
498
|
+
const { rect, bracket } = connector;
|
|
499
|
+
const inner = mainPath.slice(bracket.frame.lo, bracket.frame.hi + 1)
|
|
500
|
+
.flatMap((id) => [...(ports.get(`${id}|bottom`)?.values() || [])].map((port) => port.x));
|
|
501
|
+
const taken = [...inner, ...frameConnectors.filter((other) => other.rect === rect && other.exitX !== undefined).map((other) => other.exitX)];
|
|
502
|
+
const target = portOf(connector.lowerId, 'top', connector);
|
|
503
|
+
const low = rect.x + 16;
|
|
504
|
+
const high = rect.x + rect.width - 16;
|
|
505
|
+
const wanted = Math.min(high, Math.max(low, target.x));
|
|
506
|
+
const free = (x) => x >= low && x <= high && taken.every((other) => Math.abs(other - x) >= 14);
|
|
507
|
+
let exitX = wanted;
|
|
508
|
+
for (let step = 0; step <= 40 && !free(exitX); step += 1) {
|
|
509
|
+
exitX = [wanted + step * 7, wanted - step * 7].find(free) ?? exitX;
|
|
510
|
+
}
|
|
511
|
+
connector.exitX = exitX;
|
|
512
|
+
// Snap the target's port to the exit when that keeps its spacing.
|
|
513
|
+
const delta = Math.abs(exitX - target.x);
|
|
514
|
+
if (delta >= 0.5 && delta < 24) {
|
|
515
|
+
const state = states.get(connector.lowerId);
|
|
516
|
+
const others = [...ports.get(`${connector.lowerId}|top`).values()].filter((port) => port !== target);
|
|
517
|
+
if (exitX >= state.x + 12 && exitX <= state.x + state.width - 12 && others.every((port) => Math.abs(port.x - exitX) >= 12)) {
|
|
518
|
+
target.x = exitX;
|
|
519
|
+
} else if (delta < 16) {
|
|
520
|
+
connector.exitX = [target.x, exitX + Math.sign(exitX - target.x || 1) * (16 - delta)].find(free) ?? exitX;
|
|
210
521
|
}
|
|
211
522
|
}
|
|
212
523
|
}
|
|
524
|
+
}
|
|
213
525
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
endpointIds: new Set(states.keys()),
|
|
261
|
-
pathFor,
|
|
262
|
-
diagramType: 'lifecycle',
|
|
263
|
-
relationCollection: 'transitions',
|
|
264
|
-
profile: lifecycle.meta?.quality_profile,
|
|
265
|
-
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
|
|
266
|
-
}));
|
|
267
|
-
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
|
|
268
|
-
// shared contract wired with an explicit empty frame set so future typed
|
|
269
|
-
// lifecycle containers cannot accidentally inherit presentation geometry.
|
|
270
|
-
problems.push(...cleanBorderRunProblems({
|
|
271
|
-
relations: lifecycle.transitions,
|
|
272
|
-
endpointIds: new Set(states.keys()),
|
|
273
|
-
frames: [],
|
|
274
|
-
pathFor,
|
|
275
|
-
diagramType: 'lifecycle',
|
|
276
|
-
relationCollection: 'transitions',
|
|
277
|
-
profile: lifecycle.meta?.quality_profile
|
|
278
|
-
}));
|
|
279
|
-
problems.push(...cleanRouteRhythmProblems({
|
|
280
|
-
relations: lifecycle.transitions,
|
|
281
|
-
endpointIds: new Set(states.keys()),
|
|
282
|
-
pathFor,
|
|
283
|
-
diagramType: 'lifecycle',
|
|
284
|
-
relationCollection: 'transitions',
|
|
285
|
-
profile: lifecycle.meta?.quality_profile,
|
|
286
|
-
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
|
|
287
|
-
}));
|
|
288
|
-
|
|
289
|
-
const labelRects = [];
|
|
290
|
-
for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
|
291
|
-
if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
|
|
292
|
-
const [lx, ly] = labelPoint(transition, pathFor(transition).points);
|
|
293
|
-
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
|
294
|
-
const width = Math.max(32, longestLine * 4.9 + 12);
|
|
295
|
-
const height = transition.note ? 27 : 16;
|
|
296
|
-
labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
|
297
|
-
}
|
|
298
|
-
for (const rect of labelRects) {
|
|
299
|
-
for (const state of states.values()) {
|
|
300
|
-
if (rectsOverlap(rect, state, -2)) {
|
|
301
|
-
problems.push(`Label "${rect.label}" overlaps state "${state.id}" - adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state')}`);
|
|
526
|
+
// Each lower row starts every state under the states it connects to in the
|
|
527
|
+
// row above; overlapping blocks merge and centre on their combined ideal.
|
|
528
|
+
function placeRow(row, rowGap) {
|
|
529
|
+
const members = rows[row];
|
|
530
|
+
const ideal = new Map();
|
|
531
|
+
for (const state of members) {
|
|
532
|
+
const xs = transitions.flatMap((transition) => {
|
|
533
|
+
const other = transition.to === state.id ? transition.from : transition.from === state.id ? transition.to : null;
|
|
534
|
+
return other && depthOf(other) === row - 1 ? [states.get(other).cx] : [];
|
|
535
|
+
});
|
|
536
|
+
if (xs.length) ideal.set(state.id, xs.reduce((sum, x) => sum + x, 0) / xs.length);
|
|
537
|
+
}
|
|
538
|
+
if (row === 1) {
|
|
539
|
+
for (const bracket of brackets.values()) {
|
|
540
|
+
const center = bracket.sources.reduce((sum, id) => sum + states.get(id).cx, 0) / bracket.sources.length;
|
|
541
|
+
bracket.targets.forEach((id) => ideal.set(id, center));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const fallback = Math.max(...mainPath.map((id) => states.get(id).cx)) + 1;
|
|
545
|
+
const blocks = [];
|
|
546
|
+
const grouped = new Set();
|
|
547
|
+
for (const state of members) {
|
|
548
|
+
if (grouped.has(state.id)) continue;
|
|
549
|
+
const bracket = row === 1 ? bracketTargets.get(state.id) : null;
|
|
550
|
+
const list = bracket ? bracket.targets.map((id) => states.get(id)) : [state];
|
|
551
|
+
list.forEach((member) => grouped.add(member.id));
|
|
552
|
+
blocks.push({ members: list, ideal: ideal.get(state.id) ?? fallback, weight: ideal.has(state.id) ? list.length : 0.05, order: members.indexOf(state) });
|
|
553
|
+
}
|
|
554
|
+
// Neighbours joined by a labelled transition keep room for its label.
|
|
555
|
+
const gapBetween = (left, right) => Math.max(rowGap, ...transitions
|
|
556
|
+
.filter((transition) => hasLabel(transition) && ((transition.from === left.id && transition.to === right.id)
|
|
557
|
+
|| (transition.from === right.id && transition.to === left.id)))
|
|
558
|
+
.map((transition) => labelBox(transition).width + 32));
|
|
559
|
+
blocks.sort((a, b) => a.ideal - b.ideal || a.order - b.order);
|
|
560
|
+
// Lay a cluster of blocks side by side and return each block's centre offset.
|
|
561
|
+
const arrange = (list) => {
|
|
562
|
+
const offsets = [];
|
|
563
|
+
let x = 0;
|
|
564
|
+
let previous = null;
|
|
565
|
+
for (const block of list) {
|
|
566
|
+
const start = x;
|
|
567
|
+
for (const member of block.members) {
|
|
568
|
+
if (previous) x += gapBetween(previous, member);
|
|
569
|
+
member.offset = x;
|
|
570
|
+
x += member.width;
|
|
571
|
+
previous = member;
|
|
302
572
|
}
|
|
573
|
+
offsets.push((start + x) / 2);
|
|
574
|
+
}
|
|
575
|
+
return { width: x, offsets: offsets.map((offset) => offset - x / 2) };
|
|
576
|
+
};
|
|
577
|
+
// A cluster's centre is the weighted mean of (ideal - offset) over its
|
|
578
|
+
// blocks, which minimises their squared drift.
|
|
579
|
+
const centre = (list) => {
|
|
580
|
+
const { width, offsets } = arrange(list);
|
|
581
|
+
const weight = list.reduce((sum, block) => sum + block.weight, 0);
|
|
582
|
+
return { width, x: list.reduce((sum, block, index) => sum + block.weight * (block.ideal - offsets[index]), 0) / weight };
|
|
583
|
+
};
|
|
584
|
+
const clusters = [];
|
|
585
|
+
for (const block of blocks) {
|
|
586
|
+
clusters.push([block]);
|
|
587
|
+
while (clusters.length > 1) {
|
|
588
|
+
const last = clusters.at(-1);
|
|
589
|
+
const previous = clusters.at(-2);
|
|
590
|
+
const a = centre(previous);
|
|
591
|
+
const b = centre(last);
|
|
592
|
+
if (a.x + a.width / 2 + gapBetween(previous.at(-1).members.at(-1), last[0].members[0]) <= b.x - b.width / 2) break;
|
|
593
|
+
clusters.splice(-2, 2, [...previous, ...last]);
|
|
303
594
|
}
|
|
304
595
|
}
|
|
305
|
-
for (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
596
|
+
for (const cluster of clusters) {
|
|
597
|
+
const { x, width } = centre(cluster);
|
|
598
|
+
arrange(cluster);
|
|
599
|
+
for (const block of cluster) {
|
|
600
|
+
for (const member of block.members) {
|
|
601
|
+
member.x = x - width / 2 + member.offset;
|
|
602
|
+
member.cx = member.x + member.width / 2;
|
|
309
603
|
}
|
|
310
604
|
}
|
|
311
605
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Arcs nest by span: a shorter arc stays lower; overlapping spans never share a level.
|
|
609
|
+
function assignArcLevels() {
|
|
610
|
+
const arcs = ofKind('arc').map((transition, index) => {
|
|
611
|
+
const a = spineIndex.get(transition.from);
|
|
612
|
+
const b = spineIndex.get(transition.to);
|
|
613
|
+
return { transition, key: `arc${transitions.indexOf(transition)}`, lo: Math.min(a, b), hi: Math.max(a, b), index };
|
|
614
|
+
});
|
|
615
|
+
const sorted = [...arcs].sort((left, right) => (left.hi - left.lo) - (right.hi - right.lo) || left.lo - right.lo || left.index - right.index);
|
|
616
|
+
for (const arc of sorted) {
|
|
617
|
+
const blocking = sorted.filter((other) => other !== arc && other.level
|
|
618
|
+
&& ((other.lo < arc.hi && arc.lo < other.hi) || (other.lo === other.hi && arc.lo < other.lo && other.lo < arc.hi)
|
|
619
|
+
|| (other.lo === arc.lo && other.hi === arc.hi)));
|
|
620
|
+
arc.level = 1 + Math.max(0, ...blocking.map((other) => other.level));
|
|
621
|
+
}
|
|
622
|
+
return arcs;
|
|
623
|
+
}
|
|
321
624
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
625
|
+
// Port order on one side avoids crossings: connections toward the left use
|
|
626
|
+
// the left part of the side, connections toward the right the right part.
|
|
627
|
+
// Nested arcs put the outer (higher) arc's port furthest out and spread evenly;
|
|
628
|
+
// every other port moves toward its partner so connectors can drop straight.
|
|
629
|
+
function distributePorts(ports, arcs, mode) {
|
|
630
|
+
const arcLevel = new Map(arcs.flatMap((arc) => [[`${arc.key}:out`, arc.level], [`${arc.key}:in`, arc.level]]));
|
|
631
|
+
// Lower-row tops follow the upper ports they connect to, so place them last.
|
|
632
|
+
const lowerTop = ([slot]) => slot.endsWith('|top') && !onSpine(slot.split('|')[0]);
|
|
633
|
+
const slots = [...ports].sort((a, b) => lowerTop(a) - lowerTop(b));
|
|
634
|
+
for (const [slot, entries] of slots) {
|
|
635
|
+
const id = slot.split('|')[0];
|
|
636
|
+
const state = states.get(id);
|
|
637
|
+
const list = [...entries.values()];
|
|
638
|
+
if (lowerTop([slot])) {
|
|
639
|
+
for (const port of list) {
|
|
640
|
+
const upper = port.key?.kind === 'single' ? ports.get(`${port.key.upperId}|bottom`)?.get(port.key) : null;
|
|
641
|
+
if (upper) port.otherX = upper.x;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
list.sort((left, right) => {
|
|
645
|
+
const leftDirection = Math.sign(left.otherX - state.cx);
|
|
646
|
+
const rightDirection = Math.sign(right.otherX - state.cx);
|
|
647
|
+
if (leftDirection !== rightDirection) return leftDirection - rightDirection;
|
|
648
|
+
const leftLevel = arcLevel.get(left.key);
|
|
649
|
+
const rightLevel = arcLevel.get(right.key);
|
|
650
|
+
if (leftLevel !== undefined && rightLevel !== undefined && leftLevel !== rightLevel) {
|
|
651
|
+
// An outer arc's riser must stay outside every inner span it rises past.
|
|
652
|
+
return leftDirection < 0 ? leftLevel - rightLevel : rightLevel - leftLevel;
|
|
653
|
+
}
|
|
654
|
+
return left.otherX - right.otherX || left.order - right.order;
|
|
325
655
|
});
|
|
656
|
+
const usable = state.width - PORT_PAD * 2;
|
|
657
|
+
const step = list.length > 1 ? Math.min(PORT_STEP, usable / (list.length - 1)) : 0;
|
|
658
|
+
const xs = mode === 'even' || (onSpine(id) && slot.endsWith('|top'))
|
|
659
|
+
? list.map((_, index) => state.cx - (step * (list.length - 1)) / 2 + index * step)
|
|
660
|
+
: fitPorts(list.map((port) => port.otherX), step, state.x + PORT_PAD, state.x + state.width - PORT_PAD);
|
|
661
|
+
list.forEach((port, index) => { port.x = Math.round(xs[index] * 10) / 10; });
|
|
326
662
|
}
|
|
663
|
+
snapPorts(ports, mode);
|
|
327
664
|
}
|
|
328
665
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
666
|
+
// Ordered ports as close to their wanted x as the step and side allow: a pool
|
|
667
|
+
// of adjacent violators (isotonic regression on wanted - index * step).
|
|
668
|
+
function fitPorts(wanted, step, low, high) {
|
|
669
|
+
const n = wanted.length;
|
|
670
|
+
const top = high - step * (n - 1);
|
|
671
|
+
const clamp = (x) => Math.min(Math.max(x, low), Math.max(low, top));
|
|
672
|
+
const pools = [];
|
|
673
|
+
wanted.forEach((x, index) => {
|
|
674
|
+
pools.push({ sum: (Number.isFinite(x) ? x : x > 0 ? high : low) - index * step, count: 1 });
|
|
675
|
+
while (pools.length > 1 && pools.at(-2).sum / pools.at(-2).count > pools.at(-1).sum / pools.at(-1).count) {
|
|
676
|
+
const last = pools.pop();
|
|
677
|
+
pools.at(-1).sum += last.sum;
|
|
678
|
+
pools.at(-1).count += last.count;
|
|
337
679
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
680
|
+
});
|
|
681
|
+
return pools.flatMap((pool) => Array(pool.count).fill(clamp(pool.sum / pool.count))).map((x, index) => x + index * step);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// A nearly vertical connector becomes exactly vertical when a port can move
|
|
685
|
+
// without crowding its neighbours; otherwise its jog widens to a readable run.
|
|
686
|
+
function snapPorts(ports, mode) {
|
|
687
|
+
const fits = (id, side, moving, x) => {
|
|
688
|
+
const state = states.get(id);
|
|
689
|
+
return x >= state.x + PORT_PAD - 8 && x <= state.x + state.width - PORT_PAD + 8
|
|
690
|
+
&& [...ports.get(`${id}|${side}`).values()].every((other) => other === moving || Math.abs(other.x - x) >= 12);
|
|
691
|
+
};
|
|
692
|
+
for (const [slot, entries] of ports) {
|
|
693
|
+
if (!slot.endsWith('|top')) continue;
|
|
694
|
+
const lowerId = slot.split('|')[0];
|
|
695
|
+
if (onSpine(lowerId)) continue;
|
|
696
|
+
// Validate the final group, since neighboring ports may move together.
|
|
697
|
+
const group = [...entries.values()].sort((a, b) => a.x - b.x);
|
|
698
|
+
const state = states.get(lowerId);
|
|
699
|
+
const targets = group.map((port) => port.key?.kind === 'single'
|
|
700
|
+
? ports.get(`${port.key.upperId}|bottom`)?.get(port.key)?.x : undefined);
|
|
701
|
+
if (targets.every((x, index) => Number.isFinite(x)
|
|
702
|
+
&& x >= state.x + PORT_PAD - 8 && x <= state.x + state.width - PORT_PAD + 8
|
|
703
|
+
&& (index === 0 || x - targets[index - 1] >= 12))) {
|
|
704
|
+
group.forEach((port, index) => { port.x = targets[index]; });
|
|
705
|
+
continue;
|
|
341
706
|
}
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
707
|
+
for (const port of entries.values()) {
|
|
708
|
+
const connector = port.key;
|
|
709
|
+
if (connector?.kind === 'single') {
|
|
710
|
+
const upperPort = ports.get(`${connector.upperId}|bottom`).get(connector);
|
|
711
|
+
const delta = Math.abs(upperPort.x - port.x);
|
|
712
|
+
if (delta < 0.5 || (delta >= 16 && mode === 'even')) continue;
|
|
713
|
+
if (fits(lowerId, 'top', port, upperPort.x)) port.x = upperPort.x;
|
|
714
|
+
else if (delta >= 16) continue;
|
|
715
|
+
else if (fits(connector.upperId, 'bottom', upperPort, port.x)) upperPort.x = port.x;
|
|
716
|
+
else {
|
|
717
|
+
const away = Math.sign(port.x - upperPort.x) || 1;
|
|
718
|
+
const pushed = [upperPort.x + away * 16, upperPort.x - away * 16].find((x) => fits(lowerId, 'top', port, x));
|
|
719
|
+
if (pushed !== undefined) port.x = pushed;
|
|
720
|
+
}
|
|
721
|
+
} else if (connector?.kind === 'bracket') {
|
|
722
|
+
// A drop next to a tick would leave a micro run on the shared bus.
|
|
723
|
+
const ticks = connector.bracket.sources.map((id) => ports.get(`${id}|bottom`).get(connector).x);
|
|
724
|
+
const near = ticks.find((x) => Math.abs(x - port.x) >= 0.5 && Math.abs(x - port.x) < 16);
|
|
725
|
+
if (near !== undefined && fits(lowerId, 'top', port, near)) port.x = near;
|
|
726
|
+
}
|
|
345
727
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Greedy interval tracks for one gap, trying several orderings and keeping
|
|
732
|
+
// the one with the fewest crossings.
|
|
733
|
+
function assignTracks(runs) {
|
|
734
|
+
const needs = runs.filter((run) => !run.straight);
|
|
735
|
+
if (!needs.length) return 0;
|
|
736
|
+
const mean = (xs) => xs.reduce((sum, x) => sum + x, 0) / xs.length;
|
|
737
|
+
const direction = (run) => (run.bottoms.length ? Math.sign(mean(run.bottoms) - mean(run.tops)) : 0);
|
|
738
|
+
const start = (run) => mean(run.tops);
|
|
739
|
+
const byStart = (a, b) => (direction(a) >= 0 ? start(b) - start(a) : start(a) - start(b));
|
|
740
|
+
const loopsFirst = (a, b) => (a.bottoms.length ? 1 : 0) - (b.bottoms.length ? 1 : 0);
|
|
741
|
+
const orderings = [
|
|
742
|
+
[...needs].sort((a, b) => (direction(b) - direction(a)) || byStart(a, b)),
|
|
743
|
+
[...needs].sort((a, b) => (direction(a) - direction(b)) || byStart(a, b)),
|
|
744
|
+
[...needs].sort((a, b) => (a.hi - a.lo) - (b.hi - b.lo)),
|
|
745
|
+
[...needs].sort((a, b) => loopsFirst(a, b) || (a.hi - a.lo) - (b.hi - b.lo)),
|
|
746
|
+
[...needs].sort((a, b) => -loopsFirst(a, b) || (a.hi - a.lo) - (b.hi - b.lo)),
|
|
747
|
+
];
|
|
748
|
+
let best = null;
|
|
749
|
+
for (const ordering of orderings) {
|
|
750
|
+
const tracks = [];
|
|
751
|
+
const assignment = new Map();
|
|
752
|
+
for (const run of ordering) {
|
|
753
|
+
let track = 0;
|
|
754
|
+
while (tracks[track]?.some((other) => other.lo - 10 < run.hi && run.lo < other.hi + 10)) track += 1;
|
|
755
|
+
(tracks[track] ||= []).push(run);
|
|
756
|
+
assignment.set(run, track);
|
|
349
757
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
758
|
+
const crossings = countCrossings(runs, assignment);
|
|
759
|
+
if (!best || crossings < best.crossings || (crossings === best.crossings && tracks.length < best.count)) {
|
|
760
|
+
best = { assignment, crossings, count: tracks.length };
|
|
353
761
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
762
|
+
}
|
|
763
|
+
for (const run of needs) run.track = best.assignment.get(run);
|
|
764
|
+
return best.count;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function countCrossings(runs, assignment) {
|
|
768
|
+
// Verticals run from the upper row (y 0) to a track, then on to the lower row (y 1000).
|
|
769
|
+
const pieces = runs.map((run) => {
|
|
770
|
+
const track = assignment.get(run);
|
|
771
|
+
if (track === undefined) return { verticals: [[run.tops[0], 0, 1000]], horizontals: [] };
|
|
772
|
+
const y = 10 + track;
|
|
773
|
+
return {
|
|
774
|
+
verticals: [...run.tops.map((x) => [x, 0, y]), ...run.bottoms.map((x) => [x, y, 1000])],
|
|
775
|
+
horizontals: [[y, run.lo, run.hi]],
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
let count = 0;
|
|
779
|
+
for (let left = 0; left < pieces.length; left += 1) {
|
|
780
|
+
for (let right = 0; right < pieces.length; right += 1) {
|
|
781
|
+
if (left === right) continue;
|
|
782
|
+
for (const [x, y1, y2] of pieces[left].verticals) {
|
|
783
|
+
for (const [y, x1, x2] of pieces[right].horizontals) {
|
|
784
|
+
if (x > x1 + 0.5 && x < x2 - 0.5 && y > y1 && y < y2) count += 1;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return count;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Labels sit beside the segment that enters the target (or leaves the lower
|
|
793
|
+
// row), above main-path arrows, and on arc and lower-loop runs. Every
|
|
794
|
+
// candidate keeps clear of other routes, states and labels.
|
|
795
|
+
function placeLabels({ routes, contentRight }) {
|
|
796
|
+
const placedLabels = new Map();
|
|
797
|
+
const unplacedLabels = [];
|
|
798
|
+
const segmentsOf = (transition) => {
|
|
799
|
+
const points = routes.get(transition)?.points || [];
|
|
800
|
+
return points.slice(1).map((end, index) => ({ start: points[index], end }));
|
|
801
|
+
};
|
|
802
|
+
const siblings = (transition) => {
|
|
803
|
+
const bracket = routes.get(transition)?.bracket;
|
|
804
|
+
return bracket ? bracket.transitions.filter((other) => other.to === transition.to) : [transition];
|
|
805
|
+
};
|
|
806
|
+
const others = (transition) => transitions.filter((other) => !siblings(transition).includes(other));
|
|
807
|
+
for (const transition of transitions) {
|
|
808
|
+
const route = routes.get(transition);
|
|
809
|
+
if (!route || route.labelMode === 'none' || !hasLabel(transition)) continue;
|
|
810
|
+
const { width, height } = labelBox(transition);
|
|
811
|
+
const [start, end] = [route.points[route.segment], route.points[route.segment + 1]];
|
|
812
|
+
const candidates = [];
|
|
813
|
+
const at = (cx, cy) => ({ x: cx - width / 2, y: cy - height / 2, width, height, cx, cy });
|
|
814
|
+
if (route.labelMode === 'above') {
|
|
815
|
+
const mid = (start[0] + end[0]) / 2;
|
|
816
|
+
candidates.push(at(mid, start[1] - LABEL_GAP - height / 2), at(mid, start[1] + LABEL_GAP + height / 2));
|
|
817
|
+
} else if (route.labelMode === 'on') {
|
|
818
|
+
for (const fraction of [0.5, 0.35, 0.65, 0.2, 0.8]) {
|
|
819
|
+
candidates.push(at(start[0] + (end[0] - start[0]) * fraction, start[1]));
|
|
361
820
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
821
|
+
} else {
|
|
822
|
+
const verticals = route.points.slice(1).map((point, index) => [route.points[index], point])
|
|
823
|
+
.filter(([a, b]) => Math.abs(a[0] - b[0]) < 0.5);
|
|
824
|
+
const preferred = [[start, end], ...verticals.filter(([a]) => a !== start)];
|
|
825
|
+
const rowState = states.get(depthOf(transition.to) < depthOf(transition.from) ? transition.from : transition.to);
|
|
826
|
+
for (const [a, b] of preferred) {
|
|
827
|
+
const x = a[0];
|
|
828
|
+
const mid = (a[1] + b[1]) / 2;
|
|
829
|
+
const sides = x <= rowState.cx ? [-1, 1] : [1, -1];
|
|
830
|
+
for (const side of sides) {
|
|
831
|
+
for (const shift of [0, -10, 10]) candidates.push(at(x + side * (LABEL_GAP + width / 2), mid + shift));
|
|
832
|
+
}
|
|
365
833
|
}
|
|
366
|
-
const
|
|
367
|
-
|
|
834
|
+
const horizontal = route.points.slice(1).map((point, index) => [route.points[index], point])
|
|
835
|
+
.find(([a, b]) => Math.abs(a[1] - b[1]) < 0.5 && Math.abs(a[0] - b[0]) > width + 8);
|
|
836
|
+
if (horizontal) candidates.push(at((horizontal[0][0] + horizontal[1][0]) / 2, horizontal[0][1]));
|
|
837
|
+
}
|
|
838
|
+
const otherSegments = others(transition).flatMap(segmentsOf);
|
|
839
|
+
const ownSegments = siblings(transition).flatMap(segmentsOf);
|
|
840
|
+
const free = (rect) => rect.x >= 8 && rect.x + rect.width <= contentRight + MARGIN_X - 8 && rect.y >= 4
|
|
841
|
+
&& [...states.values()].every((state) => !rectsOverlap(rect, state, 4))
|
|
842
|
+
&& [...placedLabels.values()].every((other) => !rectsOverlap(rect, other, 4))
|
|
843
|
+
&& otherSegments.every((segment) => (segmentRectClearance(segment, rect) ?? Infinity) >= CLEARANCE)
|
|
844
|
+
&& (route.labelMode === 'on' || ownSegments.every((segment) => (segmentRectClearance(segment, rect) ?? Infinity) >= LABEL_GAP - 1));
|
|
845
|
+
const chosen = candidates.find(free);
|
|
846
|
+
if (chosen) placedLabels.set(transition, chosen);
|
|
847
|
+
else {
|
|
848
|
+
unplacedLabels.push(transition);
|
|
849
|
+
placedLabels.set(transition, candidates[0]);
|
|
368
850
|
}
|
|
369
851
|
}
|
|
852
|
+
return { placedLabels, unplacedLabels };
|
|
370
853
|
}
|
|
371
854
|
|
|
372
|
-
|
|
855
|
+
// ---------------------------------------------------------------- validate
|
|
373
856
|
|
|
374
|
-
function
|
|
375
|
-
|
|
376
|
-
const to = states.get(transition.to);
|
|
377
|
-
return {
|
|
378
|
-
fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
|
|
379
|
-
toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
|
|
380
|
-
};
|
|
857
|
+
function structureProblem(code, message, subject, supportedFixes) {
|
|
858
|
+
return { code, severity: 'error', message, subject: { diagramType: 'lifecycle', ...subject }, evidence: {}, supportedFixes };
|
|
381
859
|
}
|
|
382
860
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
861
|
+
function validateStructure() {
|
|
862
|
+
const diagnostics = [];
|
|
863
|
+
const problems = [];
|
|
864
|
+
const add = (...args) => {
|
|
865
|
+
const diagnostic = structureProblem(...args);
|
|
866
|
+
diagnostics.push(diagnostic);
|
|
867
|
+
problems.push(diagnostic.message);
|
|
868
|
+
};
|
|
869
|
+
if (states.size !== asArray(lifecycle.states).length) {
|
|
870
|
+
add('lifecycle/duplicate-state', 'State ids must be unique.', { collection: 'states' }, ['give every state a unique id']);
|
|
871
|
+
}
|
|
872
|
+
const ids = [...states.keys()];
|
|
873
|
+
mainPath.forEach((id, index) => {
|
|
874
|
+
if (!states.has(id)) {
|
|
875
|
+
add('lifecycle/unknown-main-path-state', `mainPath[${index}] "${id}" is not a state.`, { path: `/mainPath/${index}` },
|
|
876
|
+
ids.slice(0, 3).map((known) => `set /mainPath/${index} to state id "${known}"`));
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
if (new Set(mainPath).size !== mainPath.length) {
|
|
880
|
+
add('lifecycle/repeated-main-path-state', 'mainPath lists a state more than once; a return to an earlier phase is a transition, not a repeated step.',
|
|
881
|
+
{ path: '/mainPath' }, ['list each main-path state once and keep the return as a transition']);
|
|
882
|
+
}
|
|
883
|
+
transitions.forEach((transition, index) => {
|
|
884
|
+
for (const field of ['from', 'to']) {
|
|
885
|
+
if (states.has(transition[field])) continue;
|
|
886
|
+
add('lifecycle/unknown-endpoint', `Transition ${index} references unknown ${field === 'from' ? 'source' : 'target'} "${transition[field]}".`,
|
|
887
|
+
{ collection: 'transitions', path: `/transitions/${index}/${field}`, transition: transition.id ?? null },
|
|
888
|
+
ids.slice(0, 3).map((known) => `set /transitions/${index}/${field} to state id "${known}"`));
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
for (let index = 0; index + 1 < mainPath.length; index += 1) {
|
|
892
|
+
const [from, to] = [mainPath[index], mainPath[index + 1]];
|
|
893
|
+
if (!transitions.some((transition) => transition.from === from && transition.to === to)) {
|
|
894
|
+
add('lifecycle/main-path-gap', `mainPath step "${from}" -> "${to}" has no transition.`, { path: `/mainPath/${index}` },
|
|
895
|
+
[`add a transition from "${from}" to "${to}"`, 'remove the state from mainPath if it is not on the happy path']);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
asArray(lifecycle.states).forEach((state, index) => {
|
|
899
|
+
const brandRail = brandTopRailProblem(state, preferredWidth(state), TEXT.labelMinimum, 'State');
|
|
900
|
+
if (brandRail) problems.push(brandRail);
|
|
901
|
+
for (const [field, name, minimum] of [['label', 'Label', TEXT.labelMinimum], ['sublabel', 'Sublabel', TEXT.sublabelMinimum], ['tag', 'Tag', TEXT.tagMinimum]]) {
|
|
902
|
+
const value = state[field];
|
|
903
|
+
const needed = Math.ceil(minimumNodeTextWidth(value || '', minimum));
|
|
904
|
+
const available = availableNodeTextWidth(STATE_MAX_W) - (field === 'label' && brandMarkFor(state) ? 26 : 0);
|
|
905
|
+
if (value && needed > available) {
|
|
906
|
+
add('lifecycle/state-text-too-long',
|
|
907
|
+
`${name} "${value}" of state "${state.id}" is wider than the widest ${STATE_MAX_W}px state: it needs ~${needed}px at the ${minimum}px legible minimum, but ${available}px fit — shorten the ${field} and move detail into a card.`,
|
|
908
|
+
{ collection: 'states', path: `/states/${index}/${field}` }, [`shorten the ${field} and move detail into a card`]);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
if (problems.length) throwDiagnosticProblems('Lifecycle validation failed', problems, { subject: { diagramType: 'lifecycle' }, diagnostics });
|
|
913
|
+
}
|
|
386
914
|
|
|
387
|
-
function
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
let via = routeVia(transition, from, to, start, end, fromSide, toSide);
|
|
396
|
-
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
|
|
397
|
-
const midX = (start[0] + end[0]) / 2;
|
|
398
|
-
via = [[midX, start[1]], [midX, end[1]]];
|
|
399
|
-
}
|
|
400
|
-
const points = [start, ...via, end];
|
|
401
|
-
const routed = {
|
|
402
|
-
d: roundedPath(points, transition.cornerRadius ?? 10),
|
|
403
|
-
points
|
|
915
|
+
function validateLayout() {
|
|
916
|
+
const problems = [];
|
|
917
|
+
const diagnostics = [];
|
|
918
|
+
// The common width factor cancels when comparing the two text lines.
|
|
919
|
+
const widthDriver = (transition) => {
|
|
920
|
+
const label = textUnits(transition.label || '') * LABEL_FONT;
|
|
921
|
+
const note = textUnits(transition.note || '') * NOTE_FONT;
|
|
922
|
+
return label === note ? 'label+note' : label > note ? 'label' : 'note';
|
|
404
923
|
};
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
924
|
+
const preserveConditions = 'preserve every condition; shorten wording only, or move complete detail to a card explicitly associated with the same transition; shorter wording may not resolve other layout constraints';
|
|
925
|
+
if (viewBox[0] > readableWidthBudget) {
|
|
926
|
+
// A labelled main-path step widens its gap past the default spacing.
|
|
927
|
+
const wideStepLabels = mainPath.slice(0, -1).flatMap((id, index) => {
|
|
928
|
+
const edge = spineEdges.get(index);
|
|
929
|
+
const excessPx = edge && hasLabel(edge) ? Math.round(labelBox(edge).width + 26 - spacing.spineGap) : 0;
|
|
930
|
+
return excessPx > 0 ? [{ from: edge.from, to: edge.to, label: edge.label || edge.note, excessPx, widthDriver: widthDriver(edge) }] : [];
|
|
931
|
+
}).sort((a, b) => b.excessPx - a.excessPx);
|
|
932
|
+
const widenerLabels = [...spacingWideners].map((transition) => `"${transition.label || transition.note}"`);
|
|
933
|
+
const labelFixes = [
|
|
934
|
+
...(widenerLabels.length ? [`relieve the routes crowding ${widenerLabels.join(', ')}: those label/note boxes found no clear spot at the base spacing, so lower-row and route spacing grew; main-path gap growth is bounded by the text readability budget. Only equivalent exits with identical labels, notes and variants can share an arrow; keep distinct notes separate, or move a secondary transition elsewhere`] : []),
|
|
935
|
+
...(wideStepLabels.length ? [`review the width-driving main-path transition text: ${wideStepLabels.map((step) => `"${step.from}" -> "${step.to}" ${step.widthDriver.replace('+', ' and ')} (+${step.excessPx}px)`).join(', ')}; ${preserveConditions}`] : []),
|
|
936
|
+
];
|
|
937
|
+
const message = `The lifecycle is ${viewBox[0]}px wide; at ${smallestText}px text it stays readable on a desktop only up to ${readableWidthBudget}px.${wideStepLabels.length ? ` Transition labels or notes widen their main-path gaps.` : ''}`;
|
|
938
|
+
diagnostics.push({
|
|
939
|
+
code: 'lifecycle/too-wide', severity: 'error', message,
|
|
940
|
+
subject: { diagramType: 'lifecycle', path: '/mainPath' },
|
|
941
|
+
evidence: { viewBoxWidth: viewBox[0], budgetPx: readableWidthBudget, mainPathStates: mainPath.length, lowerRowStates: offStates.length, wideStepLabels, spacingWideners: [...spacingWideners].map((transition) => ({ from: transition.from, to: transition.to, label: transition.label || transition.note, widthDriver: widthDriver(transition) })) },
|
|
942
|
+
supportedFixes: [...labelFixes, 'shorten state labels and sublabels', 'move secondary phases off mainPath or merge adjacent phases', 'split the lifecycle into two diagrams'],
|
|
943
|
+
});
|
|
944
|
+
problems.push(message);
|
|
945
|
+
}
|
|
946
|
+
for (const transition of geometry.unplacedLabels) {
|
|
947
|
+
const message = `Transition "${transition.from}" -> "${transition.to}" label "${transition.label || transition.note}" cannot be placed clear of nearby routes.${transition.note ? ' Its note contributes to the label box; the box size and nearby geometry both affect placement.' : ''}`;
|
|
948
|
+
diagnostics.push({
|
|
949
|
+
code: 'lifecycle/label-unplaced', severity: 'error', message,
|
|
950
|
+
subject: { diagramType: 'lifecycle', collection: 'transitions', index: transitions.indexOf(transition), from: transition.from, to: transition.to },
|
|
951
|
+
evidence: { widthDriver: widthDriver(transition) },
|
|
952
|
+
supportedFixes: [`review the transition ${transition.note ? transition.label ? 'label and note' : 'note' : 'label'} wording; ${preserveConditions}`, 'only equivalent exits with identical labels, notes and variants can share one arrow; keep distinct notes on separate exits without removing them to force sharing'],
|
|
953
|
+
});
|
|
954
|
+
problems.push(message);
|
|
955
|
+
}
|
|
956
|
+
if (problems.length) throwDiagnosticProblems('Lifecycle layout validation failed', problems, { subject: { diagramType: 'lifecycle' }, diagnostics });
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ---------------------------------------------------------------- canvas
|
|
960
|
+
|
|
961
|
+
const LEGEND_CATALOG = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external'].map((kind) => ({
|
|
962
|
+
kind,
|
|
963
|
+
label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`),
|
|
964
|
+
swatchWidth: kind === 'start' ? 26 : undefined,
|
|
965
|
+
}));
|
|
966
|
+
|
|
967
|
+
function legendCatalog() {
|
|
968
|
+
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
|
969
|
+
presentKinds.add('start');
|
|
970
|
+
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
|
971
|
+
if (!entries.length || ![...states.values()].some(isFinal)) return entries;
|
|
972
|
+
return [...entries, { kind: 'final', label: i18nText(lifecycle.meta.locale, 'legend.lifecycle.final'), interactive: false, present: true, swatchWidth: 16 }];
|
|
419
973
|
}
|
|
974
|
+
const legendEntries = legendCatalog();
|
|
975
|
+
const canvasWidth = Math.max(640, Math.ceil(geometry.contentRight + MARGIN_X));
|
|
976
|
+
const legend = legendFootprint(legendEntries, { width: canvasWidth - 80 });
|
|
977
|
+
const viewBox = [canvasWidth, Math.ceil(geometry.contentBottom + (legendEntries.length ? 78 + legend.extraHeight : 32))];
|
|
978
|
+
validateLayout();
|
|
420
979
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
<text x="72" y="100" class="t-dim" font-size="10" font-weight="600">01 / ${esc(titles[0])}</text>
|
|
426
|
-
<path d="M 72 264 L ${right} 264" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
|
427
|
-
<text x="72" y="252" class="t-dim" font-size="10" font-weight="600">02 / ${esc(titles[1])}</text>
|
|
428
|
-
<path d="M 72 436 L ${right} 436" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
|
429
|
-
<text x="72" y="424" class="t-dim" font-size="10" font-weight="600">03 / ${esc(titles[2])}</text>`;
|
|
980
|
+
const stateSteps = new Map();
|
|
981
|
+
mainPath.forEach((id, index) => stateSteps.set(id, index));
|
|
982
|
+
for (const [index, transition] of transitions.entries()) {
|
|
983
|
+
if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
|
|
430
984
|
}
|
|
431
985
|
|
|
986
|
+
// ---------------------------------------------------------------- render
|
|
987
|
+
|
|
432
988
|
function renderState(state) {
|
|
433
989
|
const fill = typeClass[state.type] || typeClass.neutral;
|
|
434
990
|
const accent = textClass[state.type] || 't-muted';
|
|
435
991
|
const hasSub = state.sublabel != null && state.sublabel !== '';
|
|
992
|
+
const { label: labelFont, sublabel: sublabelFont, tag: tagFont } = fonts(state, state.width);
|
|
993
|
+
// Baselines keep each row's full glyph box (ascent and descent) separate.
|
|
994
|
+
const [labelY, sublabelY, tagY] = hasSub && state.tag ? [23, 40, 55] : hasSub ? [27, 45] : state.tag ? [29, 0, 50] : [37];
|
|
995
|
+
const textOffset = (state.height - STATE_H) / 2;
|
|
996
|
+
const rows = [{ text: state.label, font: labelFont, y: labelY + textOffset }];
|
|
997
|
+
if (hasSub) rows.push({ text: state.sublabel, font: sublabelFont, y: sublabelY + textOffset });
|
|
998
|
+
if (state.tag) rows.push({ text: state.tag, font: tagFont, y: tagY + textOffset });
|
|
999
|
+
const labelLayout = nodeLabelLayout({
|
|
1000
|
+
width: state.width, height: state.height, rows,
|
|
1001
|
+
brand: Boolean(brandMarkFor(state)), source: Boolean(sourceEvidence?.nodes?.[state.id]?.length), side: 'left', step: state.step,
|
|
1002
|
+
});
|
|
436
1003
|
const sub = hasSub
|
|
437
|
-
? `\n <text data-detail="context" x="${state.cx}" y="${state.y +
|
|
1004
|
+
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFont}" text-anchor="middle">${esc(state.sublabel)}</text>`
|
|
438
1005
|
: '';
|
|
439
1006
|
const tag = state.tag
|
|
440
|
-
? `\n
|
|
1007
|
+
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFont}" text-anchor="middle">${esc(state.tag)}</text>`
|
|
441
1008
|
: '';
|
|
442
|
-
const
|
|
1009
|
+
const icon = state.icon ?? (QUIET_TYPES.has(state.type) ? (isFinal(state) ? 'stop' : 'none') : undefined);
|
|
443
1010
|
const step = state.step
|
|
444
|
-
? `\n
|
|
1011
|
+
? `\n <text data-detail="fine" x="${state.x + 23}" y="${state.y + 14}" class="${accent}" font-size="${TEXT.step}" font-weight="700">${esc(state.step)}</text>`
|
|
445
1012
|
: '';
|
|
446
1013
|
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
|
|
447
|
-
const
|
|
1014
|
+
const initial = state.id === mainPath[0]
|
|
1015
|
+
? `\n <g aria-hidden="true" data-lifecycle-initial-marker="">${initialMarkerShape(state.x - 26, state.x - 1, state.cy)}</g>`
|
|
1016
|
+
: '';
|
|
1017
|
+
const finalBorder = isFinal(state)
|
|
1018
|
+
? `\n <rect x="${state.x + 3}" y="${state.y + 3}" width="${state.width - 6}" height="${state.height - 6}" rx="5" class="${fill}" style="fill: none" stroke-width="1"/>`
|
|
1019
|
+
: '';
|
|
448
1020
|
const passport = {
|
|
449
1021
|
kind: state.type,
|
|
450
1022
|
sublabel: state.sublabel,
|
|
451
1023
|
tag: state.tag,
|
|
452
|
-
context:
|
|
1024
|
+
context: i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
|
|
453
1025
|
...brandMetadataFor(state),
|
|
454
1026
|
};
|
|
455
1027
|
return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
|
|
456
1028
|
${focusNodeTitle(state.label, passport)}
|
|
457
|
-
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="
|
|
458
|
-
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="
|
|
459
|
-
${renderSemanticSigil(state.type, { x:
|
|
460
|
-
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.
|
|
1029
|
+
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="8" class="c-mask"/>
|
|
1030
|
+
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="8" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>${finalBorder}${initial}
|
|
1031
|
+
${renderSemanticSigil(state.type, { icon, x: state.x + 6, y: state.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}${step}
|
|
1032
|
+
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.x + labelLayout.x}" y="${state.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFont}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
|
461
1033
|
</g>`;
|
|
462
1034
|
}
|
|
463
1035
|
|
|
464
|
-
function
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
|
|
468
|
-
return ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
1036
|
+
function variantOf(transition) {
|
|
1037
|
+
if (transition.variant) return transition.variant;
|
|
1038
|
+
return kindOf.get(transition) === 'spine' ? 'emphasis' : 'default';
|
|
469
1039
|
}
|
|
470
1040
|
|
|
471
|
-
function
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
const [
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
const
|
|
1041
|
+
function renderTransition(transition, index) {
|
|
1042
|
+
const route = geometry.routes.get(transition);
|
|
1043
|
+
const variant = variantOf(transition);
|
|
1044
|
+
const [cls, marker] = arrowClassMap[variant] || arrowClassMap.default;
|
|
1045
|
+
const strokeWidth = variant === 'emphasis' ? 1.8 : 1.2;
|
|
1046
|
+
const d = roundedPath(route.points, CORNER);
|
|
1047
|
+
const junction = route.bracket ? ` data-composition-junction="${esc(route.bracket.key.replaceAll('\u0000', '+'))}"` : '';
|
|
1048
|
+
const edge = ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)} data-composition-points="${routePointsValue(route.points)}" data-composition-crossover="halo" data-composition-independent="true"${junction} d="${d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
1049
|
+
const underlay = ` <path data-graph-role="automatic-crossover-underlay" d="${d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>`;
|
|
1050
|
+
return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}\n${edge}\n </g>`;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function renderLabel(transition, index) {
|
|
1054
|
+
const rect = geometry.placedLabels.get(transition);
|
|
1055
|
+
if (!rect) return '';
|
|
1056
|
+
const variant = variantOf(transition);
|
|
1057
|
+
const accent = variant === 'default' ? 't-muted' : edgeLabelAccent(variant);
|
|
1058
|
+
const baseline = rect.y + (transition.label ? 11.5 : 11);
|
|
1059
|
+
const label = transition.label
|
|
1060
|
+
? `\n <text x="${rect.cx}" y="${baseline}" class="${accent}" font-size="${LABEL_FONT}" text-anchor="middle">${esc(transition.label)}</text>`
|
|
1061
|
+
: '';
|
|
478
1062
|
const note = transition.note
|
|
479
|
-
? `\n
|
|
1063
|
+
? `\n <text data-detail="fine" x="${rect.cx}" y="${baseline + (transition.label ? 12 : 0)}" class="t-muted" font-size="${NOTE_FONT}" text-anchor="middle">${esc(transition.note)}</text>`
|
|
480
1064
|
: '';
|
|
481
|
-
return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
|
|
482
|
-
<rect x="${
|
|
483
|
-
<text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
|
|
1065
|
+
return ` <g data-detail="${transition.label ? 'context' : 'fine'}" ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)}>
|
|
1066
|
+
<rect x="${rect.x}" y="${rect.y}" width="${rect.width}" height="${rect.height}" rx="4" class="c-mask"/>${label}${note}
|
|
484
1067
|
</g>`;
|
|
485
1068
|
}
|
|
486
1069
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
'
|
|
493
|
-
'
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1070
|
+
function initialMarkerShape(dotX, tipX, y) {
|
|
1071
|
+
return `<circle cx="${dotX}" cy="${y}" r="4.5" style="fill: var(--arrow-emphasis)"/><path d="M ${dotX + 4.5} ${y} L ${tipX - 6} ${y}" class="a-emphasis" stroke-width="1.4"/><path d="M ${tipX - 7} ${y - 3.5} L ${tipX} ${y} L ${tipX - 7} ${y + 3.5} Z" style="fill: var(--arrow-emphasis)"/>`;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function renderSwatch(entry) {
|
|
1075
|
+
if (entry.kind === 'start') return initialMarkerShape(entry.x + 4.5, entry.x + 24, entry.baseline - 3.5);
|
|
1076
|
+
if (entry.kind === 'final') {
|
|
1077
|
+
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-external" stroke-width="1"/><rect x="${entry.x + 2.5}" y="${entry.baseline - 5.5}" width="9" height="4" rx="1" class="c-external" style="fill: none" stroke-width="0.8"/>`;
|
|
1078
|
+
}
|
|
1079
|
+
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`;
|
|
1080
|
+
}
|
|
497
1081
|
|
|
498
1082
|
function renderLegend() {
|
|
499
|
-
const presentKinds = new Set([...states.values()].map((state) => state.type));
|
|
500
|
-
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
|
|
501
1083
|
return renderResolvedLegend({
|
|
502
|
-
entries,
|
|
1084
|
+
entries: legendEntries,
|
|
503
1085
|
locale: lifecycle.meta.locale,
|
|
504
1086
|
layout: {
|
|
505
1087
|
x: 40,
|
|
506
|
-
baselineY:
|
|
1088
|
+
baselineY: viewBox[1] - 32,
|
|
507
1089
|
width: viewBox[0] - 80,
|
|
508
|
-
minTitleY:
|
|
1090
|
+
minTitleY: geometry.contentBottom + 12,
|
|
509
1091
|
unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
|
|
510
1092
|
diagramType: 'lifecycle',
|
|
511
1093
|
},
|
|
512
|
-
renderSwatch
|
|
1094
|
+
renderSwatch,
|
|
513
1095
|
});
|
|
514
1096
|
}
|
|
515
1097
|
|
|
516
|
-
function renderLifecycleRail() {
|
|
517
|
-
const mainCols = [...states.values()]
|
|
518
|
-
.filter((state) => bandFor(state.lane) === 'phase')
|
|
519
|
-
.map((state) => state.col);
|
|
520
|
-
if (!mainCols.length) return '';
|
|
521
|
-
const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38;
|
|
522
|
-
return ` <path d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
1098
|
function renderSvg() {
|
|
526
|
-
|
|
1099
|
+
const junctions = geometry.junctions.map(([x, y]) => ` <circle cx="${x}" cy="${y}" r="2.6" data-lifecycle-junction="" style="fill: var(--arrow)" aria-hidden="true"/>`).join('\n');
|
|
1100
|
+
const frames = geometry.frames.map((rect) => ` <rect x="${rect.x}" y="${rect.y}" width="${rect.width}" height="${rect.height}" rx="14" data-lifecycle-frame="" fill="none" stroke="var(--arrow)" stroke-opacity="0.55" stroke-width="1" stroke-dasharray="1.5 3.5" stroke-linecap="round" aria-hidden="true"/>`).join('\n');
|
|
1101
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-reader-fit="intrinsic-height" data-lifecycle-layout="main-path-v3" ${svgRootAttrs(lifecycle.meta)} data-motion-choreography="walk">
|
|
527
1102
|
${svgAccessibleText(lifecycle.meta, 'lifecycle')}
|
|
528
1103
|
${renderDefinitions()}
|
|
529
1104
|
|
|
530
1105
|
<!-- Background Grid -->
|
|
531
1106
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
532
1107
|
|
|
533
|
-
<!--
|
|
534
|
-
${
|
|
535
|
-
|
|
536
|
-
<!-- Primary lifecycle rail -->
|
|
537
|
-
${renderLifecycleRail()}
|
|
1108
|
+
<!-- Composite phases -->
|
|
1109
|
+
${frames}
|
|
538
1110
|
|
|
539
1111
|
<!-- Transition paths -->
|
|
540
|
-
${
|
|
1112
|
+
${transitions.map(renderTransition).join('\n')}
|
|
1113
|
+
${junctions}
|
|
541
1114
|
|
|
542
1115
|
<!-- States -->
|
|
543
1116
|
${[...states.values()].map(renderState).join('\n\n')}
|
|
544
1117
|
|
|
545
1118
|
<!-- Transition labels -->
|
|
546
|
-
${
|
|
1119
|
+
${transitions.map(renderLabel).filter(Boolean).join('\n')}
|
|
547
1120
|
|
|
548
1121
|
<!-- Legend -->
|
|
549
1122
|
${renderLegend()}
|
|
550
1123
|
</svg>`;
|
|
551
1124
|
}
|
|
552
1125
|
|
|
553
|
-
validateLifecycle();
|
|
554
1126
|
writeDiagram({
|
|
555
1127
|
outPath,
|
|
556
1128
|
template,
|
|
@@ -558,4 +1130,5 @@ writeDiagram({
|
|
|
558
1130
|
meta: lifecycle.meta,
|
|
559
1131
|
svg: renderSvg(),
|
|
560
1132
|
cards: lifecycle.cards,
|
|
1133
|
+
sourceEvidence,
|
|
561
1134
|
});
|