@stacksjs/defaults 0.75.360 → 0.75.362
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ai/skills/stacks-payments/SKILL.md +28 -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/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -3,11 +3,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import { esc, renderDefinitions, renderSemanticSigil, textUnits } 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 { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } from '../shared/geometry.mjs';
|
|
6
|
+
import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
|
7
|
+
import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint, edgeLabelAccent } from '../shared/geometry.mjs';
|
|
8
8
|
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
|
|
9
9
|
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
|
|
10
10
|
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
11
|
+
import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX, minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
|
|
11
12
|
|
|
12
13
|
const participantTextFit = {
|
|
13
14
|
sublabelPreferred: 7,
|
|
@@ -15,54 +16,337 @@ const participantTextFit = {
|
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const { diagram: sequence, template, outPath } = await loadDiagramWithBrandMarks({
|
|
19
|
+
const { diagram: sequence, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
|
|
19
20
|
rendererDir: __dirname,
|
|
20
21
|
diagramType: 'sequence',
|
|
21
22
|
defaultExample: 'cache-miss-request.sequence.json'
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const LEGEND_CATALOG = [
|
|
26
|
+
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
|
|
27
|
+
{ kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
|
|
28
|
+
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
|
|
29
|
+
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
|
|
30
|
+
{ kind: 'default', className: 'a-default', marker: 'arrowhead' },
|
|
31
|
+
].map((entry) => ({
|
|
32
|
+
...entry,
|
|
33
|
+
interactive: false,
|
|
34
|
+
swatchWidth: 34,
|
|
35
|
+
swatchGap: 9,
|
|
36
|
+
label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
function legendEntries() {
|
|
40
|
+
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
|
41
|
+
return resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The legend sits below the timeline content: the last message and its note,
|
|
45
|
+
// activation bars, and segment frames. Its block starts LEGEND_CONTENT_GAP
|
|
46
|
+
// below that content; from the block top to the canvas bottom a one-row legend
|
|
47
|
+
// needs LEGEND_BLOCK_HEIGHT (title glyphs, row, and the 54px baseline inset).
|
|
48
|
+
const LEGEND_CONTENT_GAP = 12;
|
|
49
|
+
const LEGEND_BLOCK_HEIGHT = 86;
|
|
50
|
+
|
|
51
|
+
// A renderer-sized spread canvas widens until every participant label fits,
|
|
52
|
+
// but never past the width where 7px sublabels would project below the
|
|
53
|
+
// desktop reading minimum; an inherently crowded row still fails below.
|
|
54
|
+
const spreadParticipantWidth = (canvasWidth) => Math.max(86,
|
|
55
|
+
Math.min(190, Math.round((canvasWidth - 124) / Math.max(1, asArray(sequence.participants).length)) - 24));
|
|
56
|
+
function spreadColumnGeometry(canvasWidth, width = spreadParticipantWidth(canvasWidth)) {
|
|
57
|
+
const count = Math.max(1, asArray(sequence.participants).length);
|
|
58
|
+
const span = count * width + (count - 1) * 16;
|
|
59
|
+
const margin = Math.max(40, Math.min(62, canvasWidth - 40 - span));
|
|
60
|
+
return { width, margin, gap: count > 1 ? Math.max(width + 16, (canvasWidth - 40 - margin - width) / (count - 1)) : 108 };
|
|
61
|
+
}
|
|
62
|
+
// Evaluate the same spread coordinates and full masks used by the renderer;
|
|
63
|
+
// short participant names alone do not imply a compact timeline will fit.
|
|
64
|
+
function timelineLabelsFit(canvasWidth) {
|
|
65
|
+
const { width, margin, gap } = spreadColumnGeometry(canvasWidth);
|
|
66
|
+
const centers = new Map(asArray(sequence.participants).map((participant, index) => [participant.id, margin + width / 2 + index * gap]));
|
|
67
|
+
const unitWidth = sequence.meta?.quality_profile === 'showcase' ? 6.6 : 5.2;
|
|
68
|
+
return asArray(sequence.messages).every((message) => {
|
|
69
|
+
if (!centers.has(message.from) || !centers.has(message.to)) return true;
|
|
70
|
+
const center = (centers.get(message.from) + centers.get(message.to)) / 2;
|
|
71
|
+
const halfWidth = Math.max(34, textUnits(message.label) * unitWidth + 12) / 2;
|
|
72
|
+
return center - halfWidth >= 0 && center + halfWidth <= canvasWidth;
|
|
73
|
+
}) && asArray(sequence.segments).every((segment) => 56 + Math.max(42, textUnits(segment.label) * 5.2 + 14) <= canvasWidth - 48);
|
|
74
|
+
}
|
|
75
|
+
const readableCanvasWidth = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * participantTextFit.sublabelPreferred / MIN_PROJECTED_NODE_TEXT_PX);
|
|
76
|
+
function automaticCanvasWidth() {
|
|
77
|
+
const participantsFit = (width) => asArray(sequence.participants).every((participant) => (
|
|
78
|
+
textUnits(participant.label) * 6.8 <= width + 6
|
|
79
|
+
&& (!participant.sublabel || minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum) <= availableNodeTextWidth(width))
|
|
80
|
+
));
|
|
81
|
+
const count = Math.max(1, asArray(sequence.participants).length);
|
|
82
|
+
const columnFitMode = sequence.meta?.column_fit || 'spread';
|
|
83
|
+
if (columnFitMode !== 'spread') return 920;
|
|
84
|
+
if (participantsFit(spreadParticipantWidth(920))) {
|
|
85
|
+
// Few-participant automatic canvases: keep readable box sizes but drop the
|
|
86
|
+
// empty gutters a 920px spread leaves beside 2–3 lifelines. Authored
|
|
87
|
+
// viewBoxes and the widen-for-labels path below are unchanged.
|
|
88
|
+
if (count <= 3) {
|
|
89
|
+
for (const candidate of [560, 640, 720, 800, 920]) {
|
|
90
|
+
const box = spreadParticipantWidth(candidate);
|
|
91
|
+
const span = count * box + (count - 1) * 16;
|
|
92
|
+
if (box >= 86 && participantsFit(box) && candidate - 80 >= span && timelineLabelsFit(candidate)) return candidate;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return 920;
|
|
96
|
+
}
|
|
97
|
+
const needed = Math.max(...asArray(sequence.participants).map((participant) => Math.max(
|
|
98
|
+
textUnits(participant.label) * 6.8 - 6,
|
|
99
|
+
participant.sublabel ? minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelPreferred) + 86 - availableNodeTextWidth(86) : 0,
|
|
100
|
+
)));
|
|
101
|
+
return Math.min(readableCanvasWidth, Math.max(920, Math.ceil((Math.min(190, needed) + 25) * count + 124)));
|
|
102
|
+
}
|
|
103
|
+
const automaticWidth = sequence.meta?.viewBox ? null : automaticCanvasWidth();
|
|
104
|
+
|
|
105
|
+
// Column geometry depends only on the canvas width, which is known before the
|
|
106
|
+
// automatic height is: notes wrap inside lane gaps, and their wrapped height
|
|
107
|
+
// feeds that automatic height.
|
|
27
108
|
// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
|
|
28
109
|
// the fixed 108px gap, so a wide canvas gains column distance and label room
|
|
29
|
-
// rather than dead space on the right.
|
|
30
|
-
//
|
|
31
|
-
const
|
|
110
|
+
// rather than dead space on the right. Spread is the default on both automatic
|
|
111
|
+
// and authored canvases; explicit fixed retains historical coordinates.
|
|
112
|
+
const canvasWidth = sequence.meta?.viewBox?.[0] ?? automaticWidth;
|
|
113
|
+
const columnFit = sequence.meta?.column_fit || 'spread';
|
|
32
114
|
const participantCount = Math.max(1, asArray(sequence.participants).length);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
115
|
+
// Resolve the actual final-canvas text floor before choosing header geometry.
|
|
116
|
+
// Notes and automatic height must use the resulting column gaps below.
|
|
117
|
+
const readableMessages = sequence.meta?.quality_profile === 'showcase';
|
|
118
|
+
const readableAutomaticSublabel = readableMessages && automaticWidth !== null && columnFit === 'spread';
|
|
119
|
+
const readableSublabelMinimum = readableAutomaticSublabel
|
|
120
|
+
? Math.max(participantTextFit.sublabelMinimum, Math.ceil(minimumReadableSourceTextPx(canvasWidth) * 10) / 10)
|
|
121
|
+
: participantTextFit.sublabelMinimum;
|
|
122
|
+
const readableSublabelPreferred = Math.max(participantTextFit.sublabelPreferred, readableSublabelMinimum);
|
|
123
|
+
|
|
124
|
+
function participantSpreadGeometry() {
|
|
125
|
+
const nominal = spreadColumnGeometry(canvasWidth);
|
|
126
|
+
const participants = asArray(sequence.participants);
|
|
127
|
+
// Preserve every already-fitting row and all authored/fixed geometry. Only
|
|
128
|
+
// a failed primary label opens this bounded use of spare horizontal room.
|
|
129
|
+
if (automaticWidth === null || columnFit !== 'spread'
|
|
130
|
+
|| !participants.some((participant) => textUnits(participant.label) * 6.8 > nominal.width + 6)) return nominal;
|
|
131
|
+
const required = Math.ceil(Math.max(nominal.width, ...participants.map((participant) => Math.max(
|
|
132
|
+
textUnits(participant.label) * 6.8 - 6,
|
|
133
|
+
participant.sublabel ? minimumNodeTextWidth(participant.sublabel, readableSublabelMinimum)
|
|
134
|
+
+ nominal.width - availableNodeTextWidth(nominal.width) : 0,
|
|
135
|
+
))));
|
|
136
|
+
const capacity = Math.floor(Math.min(190, (canvasWidth - 80 - (participantCount - 1) * 16) / participantCount));
|
|
137
|
+
// Keep the existing real failures if ordinary text or the brand rail cannot
|
|
138
|
+
// fit the minimum candidate. Do not clamp a demand into infeasible geometry.
|
|
139
|
+
if (required > capacity || participants.some((participant) => brandTopRailProblem(participant, required, 8, 'Participant'))) return nominal;
|
|
140
|
+
return spreadColumnGeometry(canvasWidth, required);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const preferredSideMargin = 62;
|
|
144
|
+
const spreadGeometry = participantSpreadGeometry();
|
|
145
|
+
const participantW = columnFit === 'spread' ? spreadGeometry.width : 86;
|
|
146
|
+
// Narrow feasible frames can reduce the left margin, while ordinary frames
|
|
147
|
+
// keep 62px. Compute card width first so this does not change its sizing rule.
|
|
148
|
+
const sideMargin = columnFit === 'spread'
|
|
149
|
+
? spreadGeometry.margin
|
|
150
|
+
: preferredSideMargin;
|
|
151
|
+
// Fit the authored width when feasible, preserving a real 16px card gutter.
|
|
152
|
+
// Infeasible frames retain that minimum and fail the capacity check below.
|
|
37
153
|
const colGap = columnFit === 'spread' && participantCount > 1
|
|
38
|
-
?
|
|
154
|
+
? spreadGeometry.gap
|
|
39
155
|
: 108;
|
|
156
|
+
const leftX = columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin;
|
|
157
|
+
const participantIndexById = new Map(asArray(sequence.participants).map((participant, index) => [participant.id, index]));
|
|
158
|
+
|
|
159
|
+
function participantX(index) {
|
|
160
|
+
return leftX + index * colGap;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A note wraps inside one gap between neighbouring lifelines, so it never
|
|
164
|
+
// crosses an unrelated lifeline or activation bar (#676). Every character
|
|
165
|
+
// stays visible, including in image exports: long unbroken text (a URL or
|
|
166
|
+
// path) breaks after its punctuation, otherwise at any character.
|
|
167
|
+
const NOTE_FONT_SIZE = 7;
|
|
168
|
+
const NOTE_LINE_HEIGHT = NOTE_FONT_SIZE + 4;
|
|
169
|
+
// The first line keeps its original position: 12px past the arrow start,
|
|
170
|
+
// which sits 7px from the lifeline.
|
|
171
|
+
const NOTE_ARROW_INSET = 7;
|
|
172
|
+
const NOTE_TEXT_INSET = 12;
|
|
173
|
+
// Activation bars reach 5px past a lifeline; keep 6px of air beyond them.
|
|
174
|
+
const NOTE_LIFELINE_CLEARANCE = 11;
|
|
175
|
+
// Spread columns share one computed gap, but subtracting floating-point column
|
|
176
|
+
// centres can differ in the last digits; such gaps still count as equal.
|
|
177
|
+
const NOTE_GAP_TOLERANCE = 0.5;
|
|
178
|
+
const NOTE_BREAK_AFTER = new Set(['/', '.', '-', '?', '&', '=', '#', '_']);
|
|
179
|
+
const noteSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
180
|
+
let noteWordSegmenter;
|
|
181
|
+
const noteLayouts = new Map();
|
|
40
182
|
|
|
183
|
+
function noteTextWidth(text) {
|
|
184
|
+
return minimumNodeTextWidth(text, NOTE_FONT_SIZE);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Index after the last punctuation break in `piece`, or its full length.
|
|
188
|
+
function noteBreakIndex(piece) {
|
|
189
|
+
for (let index = piece.length - 1; index > 0; index -= 1) {
|
|
190
|
+
if (NOTE_BREAK_AFTER.has(piece[index - 1])) return index;
|
|
191
|
+
}
|
|
192
|
+
return piece.length;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function wrapNoteText(text, width) {
|
|
196
|
+
const original = wrapNoteWords(String(text).match(/\s+|\S+/gu) || [], width);
|
|
197
|
+
if (!/\p{Script=Han}/u.test(text)) return original;
|
|
198
|
+
|
|
199
|
+
// Space-only tokens can waste half a line before a Chinese phrase. Split Han
|
|
200
|
+
// words, preserving other runs (identifiers, URLs, +4) and closing punctuation.
|
|
201
|
+
noteWordSegmenter ??= new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
202
|
+
const words = [];
|
|
203
|
+
for (const token of String(text).match(/\s+|\p{Script=Han}[\p{Script=Han}\p{Mark}]*|[^\s\p{Script=Han}]+/gu) || []) {
|
|
204
|
+
if (/\p{Script=Han}/u.test(token)) {
|
|
205
|
+
for (const { segment } of noteWordSegmenter.segment(token)) words.push(segment);
|
|
206
|
+
} else if (/^[、。,;:!?)】》」』]+$/u.test(token) && words.length) {
|
|
207
|
+
words[words.length - 1] += token;
|
|
208
|
+
} else {
|
|
209
|
+
words.push(token);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const compact = wrapNoteWords(words, width);
|
|
213
|
+
// Never grow a note or change a same-height wrap merely to rebalance lines.
|
|
214
|
+
return compact.length < original.length ? compact : original;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function wrapNoteWords(words, width) {
|
|
218
|
+
const lines = [];
|
|
219
|
+
let line = '';
|
|
220
|
+
const flush = () => {
|
|
221
|
+
const trimmed = line.trimEnd();
|
|
222
|
+
if (trimmed) lines.push(trimmed);
|
|
223
|
+
line = '';
|
|
224
|
+
};
|
|
225
|
+
for (const word of words) {
|
|
226
|
+
if (/^\s+$/u.test(word)) {
|
|
227
|
+
if (line) line += ' ';
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (noteTextWidth(line + word) <= width) {
|
|
231
|
+
line += word;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
flush();
|
|
235
|
+
let piece = '';
|
|
236
|
+
for (const { segment } of noteSegmenter.segment(word)) {
|
|
237
|
+
while (piece && noteTextWidth(piece + segment) > width) {
|
|
238
|
+
const cut = noteBreakIndex(piece);
|
|
239
|
+
lines.push(piece.slice(0, cut));
|
|
240
|
+
piece = piece.slice(cut);
|
|
241
|
+
}
|
|
242
|
+
piece += segment;
|
|
243
|
+
}
|
|
244
|
+
line = piece;
|
|
245
|
+
}
|
|
246
|
+
flush();
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The widest gap the message spans; equal gaps resolve to the left-most one,
|
|
251
|
+
// where a note has always started, so notes keep their original position.
|
|
252
|
+
function noteGapIndex(fromIndex, toIndex) {
|
|
253
|
+
const low = Math.min(fromIndex, toIndex);
|
|
254
|
+
const high = Math.max(fromIndex, toIndex);
|
|
255
|
+
let best = null;
|
|
256
|
+
for (let gap = low; gap < high; gap += 1) {
|
|
257
|
+
const width = participantX(gap + 1) - participantX(gap);
|
|
258
|
+
if (!best || width > best.width + NOTE_GAP_TOLERANCE) best = { gap, width };
|
|
259
|
+
}
|
|
260
|
+
return best?.gap ?? null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function noteRect(message) {
|
|
264
|
+
const note = noteLayout(message);
|
|
265
|
+
return note && { x: note.x, y: note.top, width: note.width, height: note.bottom - note.top };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function noteLayout(message) {
|
|
269
|
+
if (noteLayouts.has(message)) return noteLayouts.get(message);
|
|
270
|
+
let result = null;
|
|
271
|
+
const fromIndex = participantIndexById.get(message.from);
|
|
272
|
+
const toIndex = participantIndexById.get(message.to);
|
|
273
|
+
if (message.note && typeof message.y === 'number' && fromIndex !== undefined && toIndex !== undefined && fromIndex !== toIndex) {
|
|
274
|
+
const gap = noteGapIndex(fromIndex, toIndex);
|
|
275
|
+
const x = participantX(gap) + NOTE_ARROW_INSET + NOTE_TEXT_INSET;
|
|
276
|
+
const maxWidth = Math.max(1, participantX(gap + 1) - NOTE_LIFELINE_CLEARANCE - x);
|
|
277
|
+
const lines = noteTextWidth(message.note) <= maxWidth ? [message.note] : wrapNoteText(message.note, maxWidth);
|
|
278
|
+
const baseline = message.y + 18;
|
|
279
|
+
result = {
|
|
280
|
+
x,
|
|
281
|
+
baseline,
|
|
282
|
+
lines,
|
|
283
|
+
width: Math.max(...lines.map(noteTextWidth)),
|
|
284
|
+
// Include fallback-font ascent in the same bounds used for title clearance.
|
|
285
|
+
top: baseline - NOTE_FONT_SIZE * 1.2,
|
|
286
|
+
bottom: message.y + 22 + (lines.length - 1) * NOTE_LINE_HEIGHT,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
noteLayouts.set(message, result);
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const contentBottom = Math.max(
|
|
294
|
+
0,
|
|
295
|
+
...asArray(sequence.messages).map((message) => (message.note
|
|
296
|
+
? noteLayout(message)?.bottom ?? message.y + 22
|
|
297
|
+
: message.y + 6)),
|
|
298
|
+
...asArray(sequence.activations).map((activation) => activation.to),
|
|
299
|
+
...asArray(sequence.segments).map((segment) => segment.to),
|
|
300
|
+
);
|
|
301
|
+
function legendRequiredHeight(width) {
|
|
302
|
+
const entries = legendEntries();
|
|
303
|
+
if (!entries.length) return 0;
|
|
304
|
+
return Math.ceil(contentBottom + LEGEND_CONTENT_GAP + LEGEND_BLOCK_HEIGHT
|
|
305
|
+
+ legendFootprint(entries, { width: width - 80 }).extraHeight);
|
|
306
|
+
}
|
|
307
|
+
// A renderer-sized canvas grows to keep the legend clear of late messages and
|
|
308
|
+
// shrinks when the timeline is short. Keep a readable minimum band (lifelineTop
|
|
309
|
+
// 142 + 120px timeline + 65px footer = 327); honor authored canvases below.
|
|
310
|
+
const SEQUENCE_MIN_AUTO_HEIGHT = 327;
|
|
311
|
+
// Timeline clearance is independent of legend visibility. Messages need 18px
|
|
312
|
+
// before the lifeline bottom; notes, activations and frames need their footer.
|
|
313
|
+
const timelineRequiredHeight = Math.max(contentBottom + 65,
|
|
314
|
+
...asArray(sequence.messages).map((message) => message.y + 18 + 65));
|
|
315
|
+
const automaticHeight = Math.max(SEQUENCE_MIN_AUTO_HEIGHT, timelineRequiredHeight, legendRequiredHeight(automaticWidth));
|
|
316
|
+
const viewBox = sequence.meta?.viewBox || [automaticWidth, automaticHeight];
|
|
317
|
+
// The timeline scales with viewBox height: a taller viewBox gains message room,
|
|
318
|
+
// a shorter one shrinks the readable band (validated below) instead of clipping.
|
|
319
|
+
|
|
320
|
+
// Showcase is the fast-authoring default; standard retains legacy label geometry.
|
|
321
|
+
const messageFontSize = readableMessages ? 11 : 9;
|
|
322
|
+
const messageUnitWidth = readableMessages ? 6.6 : 5.2;
|
|
41
323
|
const layout = {
|
|
42
324
|
topY: 72,
|
|
43
325
|
participantW,
|
|
44
|
-
|
|
326
|
+
// Keep a separate top rail for the 11px semantic sigil and 16px brand mark.
|
|
327
|
+
// Literal labels retain their fitted font size and full authored wording.
|
|
328
|
+
participantH: 60,
|
|
329
|
+
participantLabelY: 36,
|
|
330
|
+
participantSublabelY: 50,
|
|
45
331
|
lifelineTop: 142,
|
|
46
332
|
lifelineBottom: viewBox[1] - 65,
|
|
47
333
|
legendY: viewBox[1] - 54,
|
|
48
|
-
leftX
|
|
334
|
+
leftX,
|
|
49
335
|
colGap,
|
|
50
|
-
labelH: 16
|
|
336
|
+
labelH: readableMessages ? 18 : 16,
|
|
51
337
|
};
|
|
52
338
|
|
|
53
|
-
const participantBoxWidthNote =
|
|
54
|
-
? `participant boxes are ${participantW}px
|
|
55
|
-
:
|
|
339
|
+
const participantBoxWidthNote = automaticWidth
|
|
340
|
+
? `participant boxes are ${participantW}px: the automatic ${viewBox[0]}px canvas cannot widen further without its 7px sublabels falling below the desktop reading minimum, so keep meta.viewBox omitted`
|
|
341
|
+
: columnFit === 'spread'
|
|
342
|
+
? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
|
|
343
|
+
: `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
|
|
56
344
|
|
|
57
345
|
const arrowClass = {
|
|
58
346
|
...arrowClassMap,
|
|
59
347
|
return: ['a-default', 'arrowhead']
|
|
60
348
|
};
|
|
61
349
|
|
|
62
|
-
function participantX(index) {
|
|
63
|
-
return layout.leftX + index * layout.colGap;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
350
|
const participants = new Map(asArray(sequence.participants).map((participant, index) => [
|
|
67
351
|
participant.id,
|
|
68
352
|
{
|
|
@@ -90,7 +374,7 @@ function messageGeometry(message) {
|
|
|
90
374
|
function messageLabelBox(message, relationIndex = null) {
|
|
91
375
|
const geometry = messageGeometry(message);
|
|
92
376
|
if (!geometry) return null;
|
|
93
|
-
const width = Math.max(34, textUnits(message.label) *
|
|
377
|
+
const width = Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
|
|
94
378
|
return {
|
|
95
379
|
relation: message,
|
|
96
380
|
relationIndex,
|
|
@@ -113,19 +397,141 @@ function messageRouteBox(message) {
|
|
|
113
397
|
};
|
|
114
398
|
}
|
|
115
399
|
|
|
116
|
-
|
|
400
|
+
// Message labels, routes and notes a segment label must not cover, with the
|
|
401
|
+
// source path of each so a failure can name what it hides.
|
|
402
|
+
function segmentLabelOccupants() {
|
|
403
|
+
return asArray(sequence.messages).flatMap((message, messageIndex) => [
|
|
404
|
+
{ kind: 'message label', path: `/messages/${messageIndex}/label`, message, rect: messageLabelBox(message) },
|
|
405
|
+
{ kind: 'message route', path: `/messages/${messageIndex}/y`, message, rect: messageRouteBox(message) },
|
|
406
|
+
{ kind: 'note', path: `/messages/${messageIndex}/note`, message, rect: noteRect(message) },
|
|
407
|
+
]).filter((occupant) => occupant.rect);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function legacySegmentLabelBox(segment) {
|
|
117
411
|
const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
|
|
118
|
-
const occupied =
|
|
119
|
-
.flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
|
|
120
|
-
.filter(Boolean);
|
|
412
|
+
const occupied = segmentLabelOccupants();
|
|
121
413
|
const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
|
|
122
414
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
123
|
-
if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
|
|
415
|
+
if (!occupied.some(({ rect }) => rectsOverlap(label, rect, 2))) break;
|
|
124
416
|
label.y -= 22;
|
|
125
417
|
}
|
|
418
|
+
if (!segmentLabelMisplaced(label, segment)) return label;
|
|
419
|
+
// Abutting segments can leave no room on the border: the badge climbed
|
|
420
|
+
// behind the participant headers or into the previous segment, where it
|
|
421
|
+
// names the wrong phase. Its own frame's top-left corner, clear of messages,
|
|
422
|
+
// names the right one.
|
|
423
|
+
// Another segment's title normally sits on that segment's own top border,
|
|
424
|
+
// so its strip is taken too.
|
|
425
|
+
const otherTitles = asArray(sequence.segments).filter((other) => other !== segment)
|
|
426
|
+
.map((other) => ({ x: 56, y: other.from - 22, width: Math.max(42, textUnits(other.label) * 5.2 + 14), height: 18 }));
|
|
427
|
+
const inside = { x: 56, y: segment.from + 4, width: labelW, height: 18 };
|
|
428
|
+
for (let attempt = 0; attempt < 4 && inside.y + inside.height <= segment.to - 2; attempt += 1) {
|
|
429
|
+
if (!segmentLabelMisplaced(inside, segment)
|
|
430
|
+
&& ![...occupied.map(({ rect }) => rect), ...otherTitles, ...participants.values()].some((rect) => rectsOverlap(inside, rect, 2))) return inside;
|
|
431
|
+
inside.y += 22;
|
|
432
|
+
}
|
|
126
433
|
return label;
|
|
127
434
|
}
|
|
128
435
|
|
|
436
|
+
function segmentLabelBox(segment) {
|
|
437
|
+
const label = legacySegmentLabelBox(segment);
|
|
438
|
+
const headers = [...participants.values()];
|
|
439
|
+
const coveredHeaders = headers.filter((header) => rectsOverlap(label, header, 0));
|
|
440
|
+
if (!coveredHeaders.length || label.y + label.height > segment.from) return label;
|
|
441
|
+
|
|
442
|
+
const others = asArray(sequence.segments).filter((other) => other !== segment);
|
|
443
|
+
// Only the unique first, non-overlapping phase gets this local adjustment.
|
|
444
|
+
// Nested phases keep their authored bounds and the existing title policy.
|
|
445
|
+
if (others.some((other) => other.from <= segment.from
|
|
446
|
+
|| (other.from < segment.to && other.to > segment.from))) return label;
|
|
447
|
+
|
|
448
|
+
const candidate = { ...label, y: Math.max(...coveredHeaders.map((header) => header.y + header.height)) + 2 };
|
|
449
|
+
const bottom = candidate.y + candidate.height;
|
|
450
|
+
// The badge must still touch its own top border, before the first message;
|
|
451
|
+
// moving it deep into the phase would name the wrong part of the timeline.
|
|
452
|
+
if (candidate.y > segment.from || bottom < segment.from || bottom > segment.to - 2) return label;
|
|
453
|
+
|
|
454
|
+
const obstacles = [
|
|
455
|
+
...segmentLabelOccupants().map(({ rect }) => rect),
|
|
456
|
+
...headers,
|
|
457
|
+
// Use actual legacy placements: another title may have climbed above its
|
|
458
|
+
// nominal slot. Calling the unchanged helper keeps this non-recursive.
|
|
459
|
+
...others.map(legacySegmentLabelBox),
|
|
460
|
+
];
|
|
461
|
+
if (!obstacles.some((rect) => rectsOverlap(candidate, rect, 2))) return candidate;
|
|
462
|
+
|
|
463
|
+
// Only this blocked header-clear strip gains a horizontal fallback. Keep
|
|
464
|
+
// successful legacy placements intact, including their activation policy.
|
|
465
|
+
const fallbackObstacles = [
|
|
466
|
+
...obstacles,
|
|
467
|
+
...asArray(sequence.activations).flatMap((activation) => {
|
|
468
|
+
const participant = participants.get(activation.participant);
|
|
469
|
+
return participant ? [{
|
|
470
|
+
x: participant.cx - 5, y: activation.from,
|
|
471
|
+
width: 10, height: activation.to - activation.from,
|
|
472
|
+
}] : [];
|
|
473
|
+
}),
|
|
474
|
+
];
|
|
475
|
+
// Every free interval on this strip starts at the left frame inset or just
|
|
476
|
+
// beyond an obstacle. Scan those finite boundaries instead of pixel steps.
|
|
477
|
+
const boundaries = [...new Set([
|
|
478
|
+
candidate.x,
|
|
479
|
+
...fallbackObstacles
|
|
480
|
+
.filter((rect) => candidate.y < rect.y + rect.height + 2
|
|
481
|
+
&& rect.y < candidate.y + candidate.height + 2)
|
|
482
|
+
.map((rect) => rect.x + rect.width + 2),
|
|
483
|
+
])].filter((x) => x >= candidate.x && x + candidate.width <= viewBox[0] - 48)
|
|
484
|
+
.sort((a, b) => a - b);
|
|
485
|
+
for (const x of boundaries) {
|
|
486
|
+
const shifted = { ...candidate, x };
|
|
487
|
+
if (!fallbackObstacles.some((rect) => rectsOverlap(shifted, rect, 2))) return shifted;
|
|
488
|
+
}
|
|
489
|
+
return label;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Whether moving one message to `y` keeps it readable: inside the timeline,
|
|
493
|
+
// 28px from messages sharing its horizontal span, and clear of every other
|
|
494
|
+
// label, route and note. Used to offer only repairs that hold.
|
|
495
|
+
function messageFitsAt(messageIndex, y) {
|
|
496
|
+
const messages = asArray(sequence.messages);
|
|
497
|
+
if (y < layout.lifelineTop + 18) return false;
|
|
498
|
+
// An automatic canvas recalculates its height after this edit; an authored
|
|
499
|
+
// canvas must still fit the suggested position without a second repair.
|
|
500
|
+
if (sequence.meta?.viewBox && y > layout.lifelineBottom - 18) return false;
|
|
501
|
+
const span = (message) => {
|
|
502
|
+
const from = participants.get(message.from)?.cx;
|
|
503
|
+
const to = participants.get(message.to)?.cx;
|
|
504
|
+
return [Math.min(from, to), Math.max(from, to)];
|
|
505
|
+
};
|
|
506
|
+
const moved = { ...messages[messageIndex], y };
|
|
507
|
+
const [left, right] = span(moved);
|
|
508
|
+
const movedRects = [messageLabelBox(moved), messageRouteBox(moved), noteRect(moved)].filter(Boolean);
|
|
509
|
+
return messages.every((other, otherIndex) => {
|
|
510
|
+
if (otherIndex === messageIndex || typeof other.y !== 'number') return true;
|
|
511
|
+
const [otherLeft, otherRight] = span(other);
|
|
512
|
+
if (Math.abs(other.y - y) < 28 && left < otherRight && otherLeft < right) return false;
|
|
513
|
+
const otherRects = [messageLabelBox(other), messageRouteBox(other), noteRect(other)].filter(Boolean);
|
|
514
|
+
return !movedRects.some((rect) => otherRects.some((otherRect) => rectsOverlap(rect, otherRect, 2)));
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The first occupant the segment label still covers after its upward steps.
|
|
519
|
+
function segmentLabelBlocker(segment) {
|
|
520
|
+
const label = segmentLabelBox(segment);
|
|
521
|
+
return segmentLabelOccupants().find(({ rect }) => rectsOverlap(label, rect, 2)) || null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// A badge on its frame's top border is the intended title. It stops naming
|
|
525
|
+
// that frame once it hides mostly behind the participant headers, or once
|
|
526
|
+
// message labels pushed it up into another segment.
|
|
527
|
+
function segmentLabelMisplaced(label, segment) {
|
|
528
|
+
const half = label.height / 2;
|
|
529
|
+
if (layout.topY + layout.participantH - label.y > half) return true;
|
|
530
|
+
if (label.y >= segment.from - 22) return false;
|
|
531
|
+
return asArray(sequence.segments).some((other) => other !== segment
|
|
532
|
+
&& Math.min(label.y + label.height, other.to) - Math.max(label.y, other.from) > half);
|
|
533
|
+
}
|
|
534
|
+
|
|
129
535
|
const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
|
|
130
536
|
id: index,
|
|
131
537
|
label: segment.label,
|
|
@@ -147,16 +553,31 @@ function messagePath(message) {
|
|
|
147
553
|
|
|
148
554
|
function validateSequence() {
|
|
149
555
|
const problems = [];
|
|
556
|
+
const diagnostics = [];
|
|
150
557
|
if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
|
|
151
558
|
|
|
152
559
|
if (layout.lifelineBottom - layout.lifelineTop < 120) {
|
|
153
|
-
problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline
|
|
560
|
+
problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
|
|
154
561
|
}
|
|
155
562
|
|
|
156
563
|
for (const participant of participants.values()) {
|
|
564
|
+
const participantIndex = asArray(sequence.participants).findIndex((entry) => entry.id === participant.id);
|
|
565
|
+
const participantProblem = (field, message, evidence, supportedFixes) => {
|
|
566
|
+
problems.push(message);
|
|
567
|
+
diagnostics.push({
|
|
568
|
+
code: `sequence/participant-${field}-overflow`, severity: 'error', message,
|
|
569
|
+
subject: { diagramType: 'sequence', nodeId: participant.id, path: `/participants/${participantIndex}/${field}` },
|
|
570
|
+
evidence: { viewBoxWidth: viewBox[0], participantWidth: layout.participantW, ...evidence },
|
|
571
|
+
supportedFixes,
|
|
572
|
+
});
|
|
573
|
+
};
|
|
157
574
|
const estLabelW = textUnits(participant.label) * 6.8;
|
|
158
575
|
if (estLabelW > layout.participantW + 6) {
|
|
159
|
-
|
|
576
|
+
const message = automaticWidth
|
|
577
|
+
? `Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than component "${participant.id}" — shorten it to at most ${Math.floor((layout.participantW + 6) / 6.8)} text units (CJK counts 2; ${participantBoxWidthNote}).`
|
|
578
|
+
: `Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than component "${participant.id}" (${layout.participantW}px) — shorten the label or widen the participant box.`;
|
|
579
|
+
participantProblem('label', message, { text: participant.label, requiredWidth: estLabelW, availableWidth: layout.participantW + 6 },
|
|
580
|
+
['shorten the participant label while preserving its role', ...(columnFit === 'fixed' ? ['set meta.column_fit to "spread" if fixed coordinates are not required'] : [])]);
|
|
160
581
|
}
|
|
161
582
|
const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
|
|
162
583
|
if (brandRailProblem) problems.push(brandRailProblem);
|
|
@@ -164,23 +585,79 @@ function validateSequence() {
|
|
|
164
585
|
// ordinary case, this rejects what it cannot rescue.
|
|
165
586
|
if (participant.sublabel) {
|
|
166
587
|
const availableTextW = availableNodeTextWidth(layout.participantW);
|
|
167
|
-
const minimumW = minimumNodeTextWidth(participant.sublabel,
|
|
588
|
+
const minimumW = minimumNodeTextWidth(participant.sublabel, readableSublabelMinimum);
|
|
168
589
|
if (minimumW > availableTextW) {
|
|
169
|
-
|
|
590
|
+
const minimumDescription = readableAutomaticSublabel
|
|
591
|
+
? `${readableSublabelMinimum}px minimum that stays readable on this ${viewBox[0]}px canvas`
|
|
592
|
+
: `${readableSublabelMinimum}px legible minimum`;
|
|
593
|
+
const message = `Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${minimumDescription}, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`;
|
|
594
|
+
participantProblem('sublabel', message, { text: participant.sublabel, requiredWidth: minimumW, availableWidth: availableTextW, minimumFontPx: readableSublabelMinimum },
|
|
595
|
+
['shorten the participant sublabel while preserving its role or protocol; move supplementary detail into a card', ...(columnFit === 'fixed' ? ['set meta.column_fit to "spread" if fixed coordinates are not required'] : [])]);
|
|
170
596
|
}
|
|
171
597
|
}
|
|
172
598
|
}
|
|
173
599
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
600
|
+
const messageList = asArray(sequence.messages);
|
|
601
|
+
const participantList = asArray(sequence.participants);
|
|
602
|
+
const participantOrder = new Map(participantList.map((p, index) => [p.id, index]));
|
|
603
|
+
for (const message of messageList) {
|
|
604
|
+
const messageIndex = messageList.indexOf(message);
|
|
605
|
+
for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
|
|
606
|
+
if (participants.has(message[field])) continue;
|
|
607
|
+
const problem = `Message "${message.label}" references unknown ${endpoint} "${message[field]}".`;
|
|
608
|
+
const otherField = field === 'from' ? 'to' : 'from';
|
|
609
|
+
const anchorOrder = participantOrder.get(message[otherField]) ?? 0;
|
|
610
|
+
const candidates = [...participantOrder.keys()]
|
|
611
|
+
.filter((id) => id !== message[otherField])
|
|
612
|
+
.sort((a, b) => Math.abs(participantOrder.get(a) - anchorOrder) - Math.abs(participantOrder.get(b) - anchorOrder));
|
|
613
|
+
diagnostics.push({
|
|
614
|
+
code: 'sequence/unknown-endpoint', severity: 'error', message: problem,
|
|
615
|
+
subject: {
|
|
616
|
+
diagramType: 'sequence',
|
|
617
|
+
message: message.label ?? null,
|
|
618
|
+
path: `/messages/${messageIndex}/${field}`,
|
|
619
|
+
from: message.from,
|
|
620
|
+
to: message.to,
|
|
621
|
+
},
|
|
622
|
+
evidence: { endpoint, unknownNodeId: message[field], availableNodeIds: candidates },
|
|
623
|
+
supportedFixes: candidates.slice(0, 3).map((id) => `set /messages/${messageIndex}/${field} to verified node id "${id}"`),
|
|
624
|
+
});
|
|
625
|
+
problems.push(problem);
|
|
626
|
+
}
|
|
177
627
|
if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
|
|
178
628
|
if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
|
|
179
|
-
problems.push(`Message "${message.label}" sits outside the readable timeline
|
|
629
|
+
problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
|
|
180
630
|
}
|
|
181
631
|
if (participants.has(message.from) && participants.has(message.to)) {
|
|
182
|
-
|
|
183
|
-
|
|
632
|
+
if (message.from === message.to) {
|
|
633
|
+
const problem = `Message "${message.label}" is a self-message on participant "${message.from}"; this Sequence renderer supports only messages between distinct participants. Participant spacing cannot repair it. Preserve the internal step's meaning and order in a supported representation such as a note on a real message or a card; do not invent a participant.`;
|
|
634
|
+
diagnostics.push({
|
|
635
|
+
code: 'sequence/self-message-unsupported', severity: 'error', message: problem,
|
|
636
|
+
subject: {
|
|
637
|
+
diagramType: 'sequence',
|
|
638
|
+
message: message.label,
|
|
639
|
+
collection: 'messages',
|
|
640
|
+
index: messageIndex,
|
|
641
|
+
path: `/messages/${messageIndex}`,
|
|
642
|
+
...(message.id ? { id: message.id } : {}),
|
|
643
|
+
from: message.from,
|
|
644
|
+
to: message.to,
|
|
645
|
+
fromPath: `/messages/${messageIndex}/from`,
|
|
646
|
+
toPath: `/messages/${messageIndex}/to`,
|
|
647
|
+
},
|
|
648
|
+
evidence: {
|
|
649
|
+
participant: message.from,
|
|
650
|
+
participantPath: `/participants/${participantOrder.get(message.from)}`,
|
|
651
|
+
y: message.y,
|
|
652
|
+
supportedMessageGeometry: 'horizontal-between-distinct-participants',
|
|
653
|
+
},
|
|
654
|
+
supportedFixes: [],
|
|
655
|
+
});
|
|
656
|
+
problems.push(problem);
|
|
657
|
+
} else {
|
|
658
|
+
const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
|
|
659
|
+
if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
|
|
660
|
+
}
|
|
184
661
|
}
|
|
185
662
|
}
|
|
186
663
|
|
|
@@ -248,20 +725,20 @@ function validateSequence() {
|
|
|
248
725
|
for (let i = 0; i < placed.length; i += 1) {
|
|
249
726
|
for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
|
|
250
727
|
if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
|
|
251
|
-
problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space
|
|
728
|
+
problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
|
|
252
729
|
}
|
|
253
730
|
}
|
|
254
731
|
}
|
|
255
732
|
|
|
256
733
|
// Label masks can extend well past the arrow span, so check the actual
|
|
257
|
-
// label rectangles too
|
|
734
|
+
// label rectangles too — tangent arrows with long labels still collide.
|
|
258
735
|
const labelRects = asArray(sequence.messages)
|
|
259
736
|
.map((m, messageIndex) => messageLabelBox(m, messageIndex))
|
|
260
737
|
.filter(Boolean);
|
|
261
738
|
for (let i = 0; i < labelRects.length; i += 1) {
|
|
262
739
|
for (let j = i + 1; j < labelRects.length; j += 1) {
|
|
263
740
|
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
|
264
|
-
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap
|
|
741
|
+
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
|
|
265
742
|
}
|
|
266
743
|
}
|
|
267
744
|
}
|
|
@@ -275,36 +752,179 @@ function validateSequence() {
|
|
|
275
752
|
profile: sequence.meta?.quality_profile,
|
|
276
753
|
routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
|
|
277
754
|
}));
|
|
755
|
+
problems.push(...cleanLabelCanvasContainmentProblems({
|
|
756
|
+
labels: labelRects,
|
|
757
|
+
viewBox,
|
|
758
|
+
diagramType: 'sequence',
|
|
759
|
+
relationCollection: 'messages',
|
|
760
|
+
profile: sequence.meta?.quality_profile,
|
|
761
|
+
routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox',
|
|
762
|
+
}));
|
|
763
|
+
|
|
764
|
+
// A wrapped note keeps every character (#676), so a note that still reaches a
|
|
765
|
+
// later message or the canvas bottom needs the diagram changed; authored y
|
|
766
|
+
// values and viewBox stay as written. Showcase only, so standard keeps
|
|
767
|
+
// accepting existing diagrams.
|
|
768
|
+
const report = (problem, detail) => {
|
|
769
|
+
problems.push(problem);
|
|
770
|
+
diagnostics.push({ severity: 'error', message: problem, ...detail });
|
|
771
|
+
};
|
|
772
|
+
const bounds = (rect) => ({
|
|
773
|
+
x: Math.round(rect.x * 10) / 10,
|
|
774
|
+
y: Math.round(rect.y * 10) / 10,
|
|
775
|
+
width: Math.round(rect.width * 10) / 10,
|
|
776
|
+
height: Math.round(rect.height * 10) / 10,
|
|
777
|
+
});
|
|
778
|
+
if (readableMessages) {
|
|
779
|
+
const messages = asArray(sequence.messages);
|
|
780
|
+
const noteFloor = layout.lifelineBottom + 20;
|
|
781
|
+
messages.forEach((message, messageIndex) => {
|
|
782
|
+
const note = noteLayout(message);
|
|
783
|
+
if (!note) return;
|
|
784
|
+
const lines = note.lines.length === 1 ? '1 line' : `${note.lines.length} lines`;
|
|
785
|
+
const bottom = Math.ceil(note.bottom);
|
|
786
|
+
const ownRect = noteRect(message);
|
|
787
|
+
const subject = {
|
|
788
|
+
diagramType: 'sequence',
|
|
789
|
+
message: message.label ?? null,
|
|
790
|
+
path: `/messages/${messageIndex}/note`,
|
|
791
|
+
from: message.from,
|
|
792
|
+
to: message.to,
|
|
793
|
+
};
|
|
794
|
+
if (note.bottom > noteFloor) {
|
|
795
|
+
// A visible legend needs its own room below the note as well.
|
|
796
|
+
const requiredHeight = Math.max(Math.ceil(viewBox[1] + note.bottom - noteFloor), legendRequiredHeight(viewBox[0]));
|
|
797
|
+
const fix = sequence.meta?.viewBox
|
|
798
|
+
? `set /meta/viewBox/1 to ${requiredHeight}`
|
|
799
|
+
: `set /meta/viewBox to [${viewBox[0]}, ${requiredHeight}]`;
|
|
800
|
+
report(`Note on message "${message.label}" wraps to ${lines} and ends at y=${bottom}, below the canvas content limit y=${noteFloor} — set meta.viewBox[1] to at least ${requiredHeight}, move the message up, or shorten the note.`, {
|
|
801
|
+
code: 'sequence/note-canvas-limit',
|
|
802
|
+
subject,
|
|
803
|
+
evidence: { lines: note.lines.length, note: bounds(ownRect), canvasLimitY: noteFloor, viewBoxHeight: viewBox[1], requiredViewBoxHeight: requiredHeight },
|
|
804
|
+
supportedFixes: [fix],
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const blocker = messages
|
|
808
|
+
.map((other, otherIndex) => {
|
|
809
|
+
if (otherIndex === messageIndex || typeof other.y !== 'number' || other.y <= message.y) return null;
|
|
810
|
+
const hit = [
|
|
811
|
+
['message label', messageLabelBox(other)],
|
|
812
|
+
['message route', messageRouteBox(other)],
|
|
813
|
+
['note', noteRect(other)],
|
|
814
|
+
].find(([, rect]) => rect && rectsOverlap(ownRect, rect, 2));
|
|
815
|
+
return hit ? { message: other, index: otherIndex, kind: hit[0], rect: hit[1] } : null;
|
|
816
|
+
})
|
|
817
|
+
.filter(Boolean)
|
|
818
|
+
.sort((a, b) => a.message.y - b.message.y)[0];
|
|
819
|
+
if (!blocker) return;
|
|
820
|
+
const requiredY = Math.ceil(note.bottom + 22);
|
|
821
|
+
const blockerPath = `/messages/${blocker.index}/y`;
|
|
822
|
+
report(`Note on message "${message.label}" wraps to ${lines} (down to y=${bottom}) and reaches message "${blocker.message.label}" at y=${blocker.message.y} — move "${blocker.message.label}" and later messages down so it sits at y=${requiredY} or below, or shorten the note.`, {
|
|
823
|
+
code: 'sequence/note-overlap',
|
|
824
|
+
subject,
|
|
825
|
+
evidence: {
|
|
826
|
+
lines: note.lines.length,
|
|
827
|
+
note: bounds(ownRect),
|
|
828
|
+
blocker: { message: blocker.message.label ?? null, path: blockerPath, y: blocker.message.y, kind: blocker.kind, bounds: bounds(blocker.rect) },
|
|
829
|
+
requiredY,
|
|
830
|
+
},
|
|
831
|
+
supportedFixes: messageFitsAt(blocker.index, requiredY) ? [`set ${blockerPath} to ${requiredY}`] : [],
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
}
|
|
278
835
|
|
|
279
|
-
|
|
836
|
+
asArray(sequence.segments).forEach((segment, segmentIndex) => {
|
|
280
837
|
if (segment.to <= segment.from) {
|
|
281
|
-
problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to})
|
|
838
|
+
problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
|
|
282
839
|
}
|
|
283
840
|
if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
|
|
284
|
-
problems.push(`Segment "${segment.label}" extends outside the canvas
|
|
841
|
+
problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
|
|
842
|
+
}
|
|
843
|
+
// A frame edge may pass behind a masked message label, but an edge that
|
|
844
|
+
// runs along the arrow itself leaves the reader unable to tell which phase
|
|
845
|
+
// the message belongs to.
|
|
846
|
+
if (sequence.meta?.quality_profile === 'showcase') {
|
|
847
|
+
for (const edge of ['from', 'to']) {
|
|
848
|
+
const border = segment[edge];
|
|
849
|
+
const cut = asArray(sequence.messages).filter((message) => typeof message.y === 'number'
|
|
850
|
+
&& Math.abs(border - message.y) < 4);
|
|
851
|
+
for (const message of cut) {
|
|
852
|
+
const problem = `Segment "${segment.label}" ${edge === 'from' ? 'top' : 'bottom'} edge at y ${border} runs along message "${message.label}" (arrow at y ${message.y}) — move the edge to at least ${message.y + 4} to leave the message above it, or to at most ${message.y - 4} to leave it below.`;
|
|
853
|
+
const segmentIndex = asArray(sequence.segments).indexOf(segment);
|
|
854
|
+
problems.push(problem);
|
|
855
|
+
diagnostics.push({
|
|
856
|
+
code: 'sequence/segment-message-border-run', severity: 'error', message: problem,
|
|
857
|
+
subject: { diagramType: 'sequence', collection: 'segments', index: segmentIndex, path: `/segments/${segmentIndex}/${edge}` },
|
|
858
|
+
evidence: { segmentLabel: segment.label, edge, borderY: border, messageIndex: messageList.indexOf(message), messageLabel: message.label, messageY: message.y, minimumClearancePx: 4 },
|
|
859
|
+
supportedFixes: [`set /segments/${segmentIndex}/${edge} to at least ${message.y + 4} to leave the message above it`, `set /segments/${segmentIndex}/${edge} to at most ${message.y - 4} to leave the message below it`],
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
285
863
|
}
|
|
286
864
|
const labelBox = segmentLabelBox(segment);
|
|
865
|
+
for (const other of asArray(sequence.segments)) {
|
|
866
|
+
if (other === segment || asArray(sequence.segments).indexOf(other) < asArray(sequence.segments).indexOf(segment)) continue;
|
|
867
|
+
if (rectsOverlap(labelBox, segmentLabelBox(other), 0)) {
|
|
868
|
+
problems.push(`Segment labels "${segment.label}" and "${other.label}" overlap — leave more room between the segments' messages near their shared border, or shorten a label.`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
287
871
|
const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
|
|
288
872
|
if (labelBox.x + labelBox.width > viewBox[0] - 48) {
|
|
289
873
|
const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
|
|
290
|
-
problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px)
|
|
874
|
+
problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) — shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
|
|
291
875
|
}
|
|
292
|
-
|
|
876
|
+
// Segment labels are drawn above messages and notes and step up to stay
|
|
877
|
+
// clear of them; in showcase, a label left covering any of them fails.
|
|
878
|
+
const covered = readableMessages ? segmentLabelBlocker(segment) : null;
|
|
879
|
+
if (covered) {
|
|
880
|
+
// Offer the nearest start whose label lands clear, inside the segment.
|
|
881
|
+
let clearFrom = null;
|
|
882
|
+
for (let distance = 1; distance <= 300 && clearFrom === null; distance += 1) {
|
|
883
|
+
for (const candidate of [segment.from + distance, segment.from - distance]) {
|
|
884
|
+
if (candidate < layout.topY || candidate >= segment.to) continue;
|
|
885
|
+
if (!segmentLabelBlocker({ ...segment, from: candidate })) {
|
|
886
|
+
clearFrom = candidate;
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const fromPath = `/segments/${segmentIndex}/from`;
|
|
892
|
+
report(`Segment "${segment.label}" label would cover the ${covered.kind} of message "${covered.message.label}" — move the segment start (from: ${segment.from}) or that message so they are further apart${covered.kind === 'note' ? ', or shorten the note' : ''}.`, {
|
|
893
|
+
code: 'sequence/segment-label-overlap',
|
|
894
|
+
subject: { diagramType: 'sequence', segment: segment.label ?? null, path: fromPath },
|
|
895
|
+
evidence: {
|
|
896
|
+
label: bounds(labelBox),
|
|
897
|
+
attempts: 4,
|
|
898
|
+
covered: { kind: covered.kind, message: covered.message.label ?? null, path: covered.path, bounds: bounds(covered.rect) },
|
|
899
|
+
},
|
|
900
|
+
supportedFixes: clearFrom === null ? [] : [`set ${fromPath} to ${clearFrom}`],
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
});
|
|
293
904
|
|
|
294
905
|
for (const activation of asArray(sequence.activations)) {
|
|
295
906
|
if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
|
|
296
|
-
if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range
|
|
907
|
+
if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
|
|
297
908
|
}
|
|
298
909
|
|
|
299
910
|
const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
|
|
300
911
|
if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
|
|
301
912
|
const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
|
|
302
|
-
problems.push(`Participants exceed viewBox width
|
|
913
|
+
problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Showcase must not silently drop the implicit legend because late content
|
|
917
|
+
// leaves no room for it; give the exact canvas height instead.
|
|
918
|
+
const legendHeight = legendRequiredHeight(viewBox[0]);
|
|
919
|
+
if (sequence.meta?.quality_profile === 'showcase' && sequence.meta?.legend === undefined
|
|
920
|
+
&& legendHeight > viewBox[1] && !measureLegend(legendEntries(), legendLayout())) {
|
|
921
|
+
problems.push(`Sequence content ends at y=${contentBottom}, leaving no room for the legend below it — set meta.viewBox[1] to at least ${legendHeight} or omit meta.viewBox so the canvas grows.`);
|
|
303
922
|
}
|
|
304
923
|
|
|
305
924
|
if (problems.length) {
|
|
306
925
|
throwDiagnosticProblems('Sequence layout validation failed', problems, {
|
|
307
926
|
subject: { diagramType: 'sequence' },
|
|
927
|
+
diagnostics,
|
|
308
928
|
});
|
|
309
929
|
}
|
|
310
930
|
}
|
|
@@ -313,7 +933,7 @@ function renderParticipant(participant) {
|
|
|
313
933
|
const fill = componentFill[participant.type] || 'c-external';
|
|
314
934
|
const hasSub = participant.sublabel != null && participant.sublabel !== '';
|
|
315
935
|
const sub = hasSub
|
|
316
|
-
? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY +
|
|
936
|
+
? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + layout.participantSublabelY}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, readableSublabelPreferred, readableSublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
|
|
317
937
|
: '';
|
|
318
938
|
const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
|
|
319
939
|
const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
|
|
@@ -327,13 +947,20 @@ function renderParticipant(participant) {
|
|
|
327
947
|
${focusNodeTitle(participant.label, passport)}
|
|
328
948
|
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
|
|
329
949
|
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
|
|
330
|
-
${renderSemanticSigil(participant.type, { x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
|
|
331
|
-
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY +
|
|
950
|
+
${renderSemanticSigil(participant.type, { icon: participant.icon, x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
|
|
951
|
+
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + layout.participantLabelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
|
|
332
952
|
</g>`;
|
|
333
953
|
}
|
|
334
954
|
|
|
335
|
-
|
|
336
|
-
|
|
955
|
+
// Lifelines never enter the legend band. The legend is placed below all
|
|
956
|
+
// timeline content, so stopping above its title still reaches every message.
|
|
957
|
+
function lifelineEnd() {
|
|
958
|
+
const legend = measureLegend(legendEntries(), legendLayout());
|
|
959
|
+
return legend?.titleY == null ? layout.lifelineBottom : Math.min(layout.lifelineBottom, legend.titleY - 22);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function renderLifeline(participant, end) {
|
|
963
|
+
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${end}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
|
|
337
964
|
}
|
|
338
965
|
|
|
339
966
|
function renderSegment(segment, index) {
|
|
@@ -344,7 +971,7 @@ function renderSegmentLabel(segment, index) {
|
|
|
344
971
|
const label = segmentLabelBox(segment);
|
|
345
972
|
return ` <g data-graph-role="segment-label" data-segment-id="${index}">
|
|
346
973
|
<rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
|
|
347
|
-
<text x="${label.x + 6}" y="${label.y + 13}" class="t-
|
|
974
|
+
<text x="${label.x + 6}" y="${label.y + 13}" class="t-muted" font-size="9" font-weight="600">${esc(segment.label)}</text>
|
|
348
975
|
</g>`;
|
|
349
976
|
}
|
|
350
977
|
|
|
@@ -361,69 +988,82 @@ function messageLabel(message, x1, x2) {
|
|
|
361
988
|
const box = messageLabelBox(message);
|
|
362
989
|
const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
|
|
363
990
|
const y = message.y - 10;
|
|
364
|
-
const labelW = box?.width || Math.max(34, textUnits(message.label) *
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
? 't-messagebus'
|
|
369
|
-
: message.variant === 'return'
|
|
370
|
-
? 't-muted'
|
|
371
|
-
: 't-backend';
|
|
991
|
+
const labelW = box?.width || Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
|
|
992
|
+
// A colored line gets a label in the same color, as in the legend swatches.
|
|
993
|
+
// Gray lines (default and return) keep the readable muted text color.
|
|
994
|
+
const accent = ['emphasis', 'security', 'dashed'].includes(message.variant) ? edgeLabelAccent(message.variant) : 't-muted';
|
|
372
995
|
return ` <g data-detail="context">
|
|
373
996
|
<rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
|
|
374
|
-
<text x="${center}" y="${y}" class="${accent}" font-size="
|
|
997
|
+
<text x="${center}" y="${y}" class="${accent}" font-size="${messageFontSize}" text-anchor="middle">${esc(message.label)}</text>
|
|
375
998
|
</g>`;
|
|
376
999
|
}
|
|
377
1000
|
|
|
1001
|
+
function noteBackdrop(message) {
|
|
1002
|
+
const note = noteRect(message);
|
|
1003
|
+
if (!note) return '';
|
|
1004
|
+
const box = { x: note.x - 2, y: note.y - 2, width: note.width + 4, height: note.height + 4 };
|
|
1005
|
+
const crossed = asArray(sequence.segments).some((segment) => [segment.from, segment.to]
|
|
1006
|
+
.some((border) => border >= box.y && border <= box.y + box.height));
|
|
1007
|
+
if (!crossed) return '';
|
|
1008
|
+
// The backdrop clears decorative frames, never another message. Standard
|
|
1009
|
+
// quality can retain overlapping annotations that showcase rejects.
|
|
1010
|
+
const coversMessage = asArray(sequence.messages).some((other) => other !== message
|
|
1011
|
+
&& [messageLabelBox(other), messageRouteBox(other), noteRect(other)]
|
|
1012
|
+
.some((rect) => rect && rectsOverlap(box, rect, 0)));
|
|
1013
|
+
if (coversMessage) return '';
|
|
1014
|
+
return `\n <rect data-detail="context" data-graph-role="message-note-background" x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="2" class="c-mask" pointer-events="none"/>`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
378
1017
|
function renderMessage(message, index) {
|
|
379
1018
|
const { start, end } = messageGeometry(message);
|
|
380
1019
|
const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
|
|
381
1020
|
const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
|
|
382
1021
|
const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
|
|
383
|
-
const
|
|
384
|
-
|
|
1022
|
+
const noteBox = noteLayout(message);
|
|
1023
|
+
const noteContent = noteBox?.lines.length > 1
|
|
1024
|
+
? noteBox.lines.map((line, lineIndex) => `<tspan x="${noteBox.x}" dy="${lineIndex ? NOTE_LINE_HEIGHT : 0}">${esc(line)}</tspan>`).join('')
|
|
1025
|
+
: esc(message.note);
|
|
1026
|
+
// Notes carry message conditions and already reserve timeline space. Keep
|
|
1027
|
+
// them readable at the default depth; Map still hides context without reflow.
|
|
1028
|
+
const note = noteBox
|
|
1029
|
+
? `\n <text data-detail="context" x="${noteBox.x}" y="${noteBox.baseline}" class="t-muted" font-size="${NOTE_FONT_SIZE}">${noteContent}</text>`
|
|
385
1030
|
: '';
|
|
386
1031
|
return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
|
|
387
1032
|
<path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
|
|
388
|
-
${messageLabel(message, start, end)}${note}
|
|
1033
|
+
${messageLabel(message, start, end)}${noteBackdrop(message)}${note}
|
|
389
1034
|
</g>`;
|
|
390
1035
|
}
|
|
391
1036
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
1037
|
+
|
|
1038
|
+
function legendLayout() {
|
|
1039
|
+
return {
|
|
1040
|
+
x: 40,
|
|
1041
|
+
baselineY: layout.legendY,
|
|
1042
|
+
width: viewBox[0] - 80,
|
|
1043
|
+
// The same content-based budget as legendRequiredHeight(): a wrapped
|
|
1044
|
+
// legend may use any rows it needs as long as it stays below the content.
|
|
1045
|
+
minTitleY: Math.max(layout.lifelineTop, contentBottom + LEGEND_CONTENT_GAP),
|
|
1046
|
+
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
|
1047
|
+
diagramType: 'sequence',
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
405
1050
|
|
|
406
1051
|
function renderLegend() {
|
|
407
|
-
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
|
408
|
-
const entries = resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
|
409
1052
|
return renderResolvedLegend({
|
|
410
|
-
entries,
|
|
1053
|
+
entries: legendEntries(),
|
|
411
1054
|
locale: sequence.meta.locale,
|
|
412
|
-
layout:
|
|
413
|
-
x: 40,
|
|
414
|
-
baselineY: layout.legendY,
|
|
415
|
-
width: viewBox[0] - 80,
|
|
416
|
-
minTitleY: layout.legendY - 30,
|
|
417
|
-
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
|
418
|
-
diagramType: 'sequence',
|
|
419
|
-
},
|
|
1055
|
+
layout: legendLayout(),
|
|
420
1056
|
renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
|
|
421
1057
|
});
|
|
422
1058
|
}
|
|
423
1059
|
|
|
424
1060
|
function renderSvg() {
|
|
425
1061
|
const participantList = [...participants.values()];
|
|
426
|
-
|
|
1062
|
+
// Same default-canvas contract as lifecycle: 920x760 is below the 1.55 wide
|
|
1063
|
+
// ratio, so without intrinsic-height the desktop Reader can neither narrow
|
|
1064
|
+
// nor scroll it and every default sequence fails the browser gate.
|
|
1065
|
+
const readerFit = sequence.meta?.viewBox ? '' : ' data-reader-fit="width-first"';
|
|
1066
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-sequence-column-fit="${columnFit}"${readerFit} ${svgRootAttrs(sequence.meta)} data-motion-choreography="sequence">
|
|
427
1067
|
${svgAccessibleText(sequence.meta, 'sequence')}
|
|
428
1068
|
${renderDefinitions()}
|
|
429
1069
|
|
|
@@ -434,7 +1074,7 @@ ${renderDefinitions()}
|
|
|
434
1074
|
${asArray(sequence.segments).map(renderSegment).join('\n\n')}
|
|
435
1075
|
|
|
436
1076
|
<!-- Lifelines -->
|
|
437
|
-
${participantList.map(renderLifeline).join('\n')}
|
|
1077
|
+
${participantList.map((participant) => renderLifeline(participant, lifelineEnd())).join('\n')}
|
|
438
1078
|
|
|
439
1079
|
<!-- Activations -->
|
|
440
1080
|
${asArray(sequence.activations).map(renderActivation).join('\n')}
|
|
@@ -461,4 +1101,5 @@ writeDiagram({
|
|
|
461
1101
|
meta: sequence.meta,
|
|
462
1102
|
svg: renderSvg(),
|
|
463
1103
|
cards: sequence.cards,
|
|
1104
|
+
sourceEvidence,
|
|
464
1105
|
});
|