@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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import {
|
|
5
|
+
animateAttr,
|
|
6
|
+
focusEdgeAttrs,
|
|
7
|
+
focusNodeAttrs,
|
|
8
|
+
focusNodeTitle,
|
|
9
|
+
loadDiagram,
|
|
10
|
+
svgAccessibleText,
|
|
11
|
+
svgRootAttrs,
|
|
12
|
+
writeDiagram,
|
|
13
|
+
} from '../shared/cli.mjs';
|
|
14
|
+
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
15
|
+
import { nodeTextFit } from '../shared/text-fit.mjs';
|
|
16
|
+
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
17
|
+
import { asArray, roundedPath, routePointsValue } from '../shared/geometry.mjs';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const layoutJsonMode = process.argv.includes('--layout-json');
|
|
21
|
+
const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
|
|
22
|
+
const { diagram: tree, template, outPath, sourceEvidence } = loadDiagram({
|
|
23
|
+
rendererDir: __dirname,
|
|
24
|
+
diagramType: 'tree',
|
|
25
|
+
defaultExample: 'payment-platform.tree.json',
|
|
26
|
+
argv: cliArgs,
|
|
27
|
+
});
|
|
28
|
+
const locale = tree.meta.locale;
|
|
29
|
+
const direction = tree.layout?.direction === 'right' ? 'right' : 'down';
|
|
30
|
+
const down = direction === 'down';
|
|
31
|
+
|
|
32
|
+
const layout = {
|
|
33
|
+
margin: 32,
|
|
34
|
+
top: 64,
|
|
35
|
+
// Sibling gap and generation gap. A downward tree needs room under each
|
|
36
|
+
// parent for its toggle and the elbow; a rightward one needs room beside it.
|
|
37
|
+
gapX: tree.layout?.gapX ?? (direction === 'down' ? 28 : 48),
|
|
38
|
+
gapY: tree.layout?.gapY ?? (direction === 'down' ? 56 : 18),
|
|
39
|
+
nodeW: tree.layout?.nodeW ?? 140,
|
|
40
|
+
nodeMaxW: tree.layout?.nodeMaxW ?? (direction === 'down' ? 220 : 200),
|
|
41
|
+
padX: 14,
|
|
42
|
+
padY: 12,
|
|
43
|
+
labelFont: 13,
|
|
44
|
+
labelLine: 17,
|
|
45
|
+
sublabelFont: 11,
|
|
46
|
+
sublabelLine: 14,
|
|
47
|
+
rootLabelFont: 14.5,
|
|
48
|
+
toggleR: 7.5,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ---- Structure -----------------------------------------------------------------
|
|
52
|
+
// The hierarchy is exactly what the author declared: one root, and every other
|
|
53
|
+
// node names its parent. Nothing is inferred, so a missing parent, a cycle, or
|
|
54
|
+
// a second root is refused rather than silently attached somewhere.
|
|
55
|
+
function validateStructure() {
|
|
56
|
+
const problems = [];
|
|
57
|
+
const details = [];
|
|
58
|
+
const fail = (code, message, subject, evidence, supportedFixes) => {
|
|
59
|
+
problems.push(message);
|
|
60
|
+
details.push({ code, severity: 'error', message, subject: { diagramType: 'tree', ...subject }, evidence, supportedFixes });
|
|
61
|
+
};
|
|
62
|
+
const nodes = asArray(tree.nodes);
|
|
63
|
+
const byId = new Map();
|
|
64
|
+
for (const [index, node] of nodes.entries()) {
|
|
65
|
+
if (byId.has(node.id)) {
|
|
66
|
+
fail('tree/duplicate-id', `Node id "${node.id}" is declared twice.`, { path: `/nodes/${index}/id` }, { id: node.id }, ['Give every node a unique id.']);
|
|
67
|
+
}
|
|
68
|
+
byId.set(node.id, node);
|
|
69
|
+
}
|
|
70
|
+
for (const [index, node] of nodes.entries()) {
|
|
71
|
+
if (node.parent !== undefined && !byId.has(node.parent)) {
|
|
72
|
+
fail('tree/missing-parent', `Node "${node.id}" names parent "${node.parent}", which is not declared.`,
|
|
73
|
+
{ path: `/nodes/${index}/parent`, nodeId: node.id }, { parent: node.parent },
|
|
74
|
+
[`Declare a node with id "${node.parent}".`, 'Point parent at an existing node.', 'Remove parent to make this the root.']);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const roots = nodes.filter((node) => node.parent === undefined);
|
|
78
|
+
if (roots.length !== 1) {
|
|
79
|
+
fail('tree/root-count', roots.length
|
|
80
|
+
? `A tree has one root, but ${roots.length} nodes have no parent: ${roots.map((node) => `"${node.id}"`).join(', ')}.`
|
|
81
|
+
: 'A tree has one root, but every node names a parent.',
|
|
82
|
+
{ path: '/nodes' }, { roots: roots.map((node) => node.id) },
|
|
83
|
+
roots.length ? ['Give all but one of these nodes a parent.', 'Add a single root that the top-level nodes name as parent.'] : ['Remove parent from the root node.']);
|
|
84
|
+
}
|
|
85
|
+
// Walk each node's parent chain. With one root and every parent declared,
|
|
86
|
+
// the only way a node can fail to reach the root is a cycle in its chain,
|
|
87
|
+
// which also accounts for every node hanging beneath that cycle.
|
|
88
|
+
const reported = new Set();
|
|
89
|
+
for (const [index, node] of nodes.entries()) {
|
|
90
|
+
const chain = [];
|
|
91
|
+
let current = node;
|
|
92
|
+
while (current && current.parent !== undefined && !chain.includes(current.id)) {
|
|
93
|
+
chain.push(current.id);
|
|
94
|
+
current = byId.get(current.parent);
|
|
95
|
+
}
|
|
96
|
+
if (current && chain.includes(current.id)) {
|
|
97
|
+
const cycle = chain.slice(chain.indexOf(current.id));
|
|
98
|
+
const key = [...cycle].sort().join('|');
|
|
99
|
+
if (!reported.has(key)) {
|
|
100
|
+
reported.add(key);
|
|
101
|
+
fail('tree/cycle', `Parent links form a cycle: ${[...cycle, cycle[0]].map((id) => `"${id}"`).join(' -> ')}.`,
|
|
102
|
+
{ path: `/nodes/${index}/parent`, nodeId: node.id }, { cycle },
|
|
103
|
+
['Point one node in the cycle at a parent outside it.']);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (problems.length) {
|
|
108
|
+
throwDiagnosticProblems('Tree structure validation failed', problems, { code: 'tree/structure', subject: { diagramType: 'tree' }, diagnostics: details });
|
|
109
|
+
}
|
|
110
|
+
return { byId, root: roots[0] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { root: authoredRoot } = validateStructure();
|
|
114
|
+
|
|
115
|
+
const childrenOf = new Map(asArray(tree.nodes).map((node) => [node.id, []]));
|
|
116
|
+
for (const node of asArray(tree.nodes)) if (node.parent !== undefined) childrenOf.get(node.parent).push(node.id);
|
|
117
|
+
|
|
118
|
+
// ---- Measurement ---------------------------------------------------------------
|
|
119
|
+
// Labels wrap at word boundaries (CJK at any character) inside a bounded box,
|
|
120
|
+
// so a long name grows the node downward instead of overflowing or being cut.
|
|
121
|
+
const ADVANCE = nodeTextFit.widthFactor;
|
|
122
|
+
|
|
123
|
+
function wrapText(text, font, maxWidth) {
|
|
124
|
+
const limit = Math.max(4, Math.floor(maxWidth / (font * ADVANCE)));
|
|
125
|
+
if (textUnits(text) <= limit) return [text];
|
|
126
|
+
const tokens = String(text).match(/[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]|[^\s\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]+\s*|\s+/g) || [text];
|
|
127
|
+
const lines = [];
|
|
128
|
+
let current = '';
|
|
129
|
+
for (const token of tokens) {
|
|
130
|
+
if (textUnits((current + token).trimEnd()) <= limit) {
|
|
131
|
+
current += token;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (current.trim()) lines.push(current.trimEnd());
|
|
135
|
+
current = token.trimStart();
|
|
136
|
+
while (textUnits(current.trimEnd()) > limit) {
|
|
137
|
+
const chars = Array.from(current);
|
|
138
|
+
let cut = 0;
|
|
139
|
+
for (let units = 0; cut < chars.length && units + textUnits(chars[cut]) <= limit; cut += 1) units += textUnits(chars[cut]);
|
|
140
|
+
lines.push(chars.slice(0, Math.max(1, cut)).join(''));
|
|
141
|
+
current = chars.slice(Math.max(1, cut)).join('');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (current.trim()) lines.push(current.trimEnd());
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const depthOf = new Map();
|
|
149
|
+
(function assignDepth(id, depth) {
|
|
150
|
+
depthOf.set(id, depth);
|
|
151
|
+
for (const child of childrenOf.get(id)) assignDepth(child, depth + 1);
|
|
152
|
+
}(authoredRoot.id, 0));
|
|
153
|
+
|
|
154
|
+
// Compact leaf runs (going down only): three or more consecutive leaf
|
|
155
|
+
// children of one parent are stacked as an indented list under it instead of
|
|
156
|
+
// being spread across the row. A wide fan of leaves is what makes a real
|
|
157
|
+
// hierarchy (a repository, a capability map) far wider than any screen; a list
|
|
158
|
+
// keeps it readable at the same scale. Shorter runs and every branch keep the
|
|
159
|
+
// classic row layout.
|
|
160
|
+
const STACK_MIN = 3;
|
|
161
|
+
const STACK_GAP = 10;
|
|
162
|
+
const stackOf = new Map();
|
|
163
|
+
const groupsOf = new Map();
|
|
164
|
+
for (const [id, children] of childrenOf) {
|
|
165
|
+
const groups = [];
|
|
166
|
+
let run = [];
|
|
167
|
+
const flush = () => {
|
|
168
|
+
if (down && run.length >= STACK_MIN) groups.push({ stack: run });
|
|
169
|
+
else for (const child of run) groups.push({ single: child });
|
|
170
|
+
run = [];
|
|
171
|
+
};
|
|
172
|
+
for (const child of children) {
|
|
173
|
+
if (childrenOf.get(child).length) {
|
|
174
|
+
flush();
|
|
175
|
+
groups.push({ single: child });
|
|
176
|
+
} else run.push(child);
|
|
177
|
+
}
|
|
178
|
+
flush();
|
|
179
|
+
// A list that is a parent's only child group drops its spine from inside
|
|
180
|
+
// the parent's box, clear of the rounded corner; one beside other children
|
|
181
|
+
// hangs off the shared crossbar.
|
|
182
|
+
for (const group of groups) {
|
|
183
|
+
if (!group.stack) continue;
|
|
184
|
+
group.spineOffset = groups.length === 1 ? 22 : 12;
|
|
185
|
+
group.indent = group.spineOffset + 16;
|
|
186
|
+
}
|
|
187
|
+
groupsOf.set(id, groups);
|
|
188
|
+
for (const group of groups) if (group.stack) for (const child of group.stack) stackOf.set(child, group);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Width: a label wraps only when it must, and then at the narrowest width
|
|
192
|
+
// that keeps the same number of lines, so a long name becomes a balanced
|
|
193
|
+
// two-line box instead of one wide box beside narrow siblings.
|
|
194
|
+
const measured = new Map(asArray(tree.nodes).map((node) => {
|
|
195
|
+
const isRoot = node.id === authoredRoot.id;
|
|
196
|
+
const labelFont = isRoot ? layout.rootLabelFont : layout.labelFont;
|
|
197
|
+
const labelAdvance = labelFont * ADVANCE * 1.06;
|
|
198
|
+
// Text wraps between words, never inside one: the longest single word may
|
|
199
|
+
// widen a node past nodeMaxW (up to half again), and only a word longer
|
|
200
|
+
// than that is split.
|
|
201
|
+
const longestWord = Math.max(...String(node.label).split(/\s+/).map((word) => textUnits(word) * labelAdvance));
|
|
202
|
+
const floor = Math.max(layout.nodeW, Math.ceil(longestWord + layout.padX * 2 + 2));
|
|
203
|
+
const cap = Math.ceil(Math.min(layout.nodeMaxW * 1.5, Math.max(floor, layout.nodeMaxW)));
|
|
204
|
+
const linesAt = (width) => ({
|
|
205
|
+
label: wrapText(node.label, labelFont * 1.06, width - layout.padX * 2),
|
|
206
|
+
sublabel: node.sublabel ? wrapText(node.sublabel, layout.sublabelFont, width - layout.padX * 2) : [],
|
|
207
|
+
});
|
|
208
|
+
const natural = Math.ceil(Math.max(textUnits(node.label) * labelAdvance, node.sublabel ? textUnits(node.sublabel) * layout.sublabelFont * ADVANCE : 0) + layout.padX * 2 + 2);
|
|
209
|
+
// A stacked list shares one width anyway, so its entries are not balanced.
|
|
210
|
+
const stacked = stackOf.has(node.id);
|
|
211
|
+
let width = Math.min(cap, Math.max(floor, natural));
|
|
212
|
+
const target = linesAt(width);
|
|
213
|
+
for (let candidate = floor; !stacked && candidate < width; candidate += 4) {
|
|
214
|
+
const lines = linesAt(candidate);
|
|
215
|
+
if (lines.label.length <= target.label.length && lines.sublabel.length <= target.sublabel.length) {
|
|
216
|
+
width = candidate;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const { label: labelLines, sublabel: sublabelLines } = linesAt(width);
|
|
221
|
+
const contentHeight = labelLines.length * layout.labelLine + (sublabelLines.length ? 4 + sublabelLines.length * layout.sublabelLine : 0);
|
|
222
|
+
// Verified source badges occupy the top-right 18px of a card. Give sourced
|
|
223
|
+
// nodes a separate header so centred and wrapped labels never sit under it;
|
|
224
|
+
// diagrams without sources retain their existing geometry.
|
|
225
|
+
const evidenceHeader = sourceEvidence?.nodes?.[node.id]?.length ? 18 : 0;
|
|
226
|
+
return [node.id, { ...node, isRoot, labelFont, labelLines, sublabelLines, width, height: Math.ceil(layout.padY * 2 + evidenceHeader + contentHeight), contentHeight, evidenceHeader, depth: depthOf.get(node.id), stacked }];
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
// A stacked run reads as one list: one width, one row height.
|
|
230
|
+
for (const group of new Set(stackOf.values())) {
|
|
231
|
+
const members = group.stack.map((id) => measured.get(id));
|
|
232
|
+
group.width = Math.max(...members.map((node) => node.width));
|
|
233
|
+
group.rowH = Math.max(...members.map((node) => node.height));
|
|
234
|
+
for (const node of members) measured.set(node.id, { ...node, width: group.width, height: group.rowH });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- Layout --------------------------------------------------------------------
|
|
238
|
+
// Every generation shares one band (a row going down, a column going right),
|
|
239
|
+
// sized by its largest node, so siblings and cousins line up. Along the other
|
|
240
|
+
// axis each subtree owns a contiguous span: leaves are laid out in order and a
|
|
241
|
+
// parent is centred over its children. A stacked run hangs from its parent's
|
|
242
|
+
// band and owns its own span, so it never meets a cousin. Positions never
|
|
243
|
+
// depend on collapse state, so expanding or collapsing a branch leaves every
|
|
244
|
+
// visible node where the reader last saw it.
|
|
245
|
+
const breadthSize = (node) => (down ? node.width : node.height);
|
|
246
|
+
const depthSize = (node) => (down ? node.height : node.width);
|
|
247
|
+
const breadthGap = down ? layout.gapX : layout.gapY;
|
|
248
|
+
const depthGap = down ? layout.gapY : layout.gapX;
|
|
249
|
+
|
|
250
|
+
const maxDepth = Math.max(...[...measured.values()].map((node) => node.depth));
|
|
251
|
+
const bandSize = Array.from({ length: maxDepth + 1 }, () => 0);
|
|
252
|
+
for (const node of measured.values()) if (!node.stacked) bandSize[node.depth] = Math.max(bandSize[node.depth], depthSize(node));
|
|
253
|
+
const bandStart = [];
|
|
254
|
+
bandSize.reduce((offset, size, depth) => {
|
|
255
|
+
bandStart[depth] = offset;
|
|
256
|
+
return offset + size + depthGap;
|
|
257
|
+
}, down ? layout.top : layout.margin);
|
|
258
|
+
|
|
259
|
+
// Nodes of one generation share their band's size along the depth axis (the
|
|
260
|
+
// same height going down, the same width going right), so a generation reads
|
|
261
|
+
// as one even row or column instead of a ragged one.
|
|
262
|
+
for (const [id, node] of measured) {
|
|
263
|
+
if (node.stacked) continue;
|
|
264
|
+
measured.set(id, down ? { ...node, height: bandSize[node.depth] } : { ...node, width: bandSize[node.depth] });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const groupSpan = (group) => (group.stack ? group.indent + group.width : span.get(group.single));
|
|
268
|
+
const groupsSpan = (groups) => groups.reduce((sum, group, index) => sum + groupSpan(group) + (index ? breadthGap : 0), 0);
|
|
269
|
+
const span = new Map();
|
|
270
|
+
(function measureSpan(id) {
|
|
271
|
+
for (const child of childrenOf.get(id)) measureSpan(child);
|
|
272
|
+
span.set(id, Math.max(breadthSize(measured.get(id)), groupsSpan(groupsOf.get(id))));
|
|
273
|
+
}(authoredRoot.id));
|
|
274
|
+
|
|
275
|
+
const placed = new Map();
|
|
276
|
+
// Where a group meets its parent's crossbar: a single child at its centre, a
|
|
277
|
+
// stacked run at its spine.
|
|
278
|
+
const attachOf = (group) => (group.stack ? group.spine : centerOf(group.single));
|
|
279
|
+
(function place(id, start) {
|
|
280
|
+
const node = measured.get(id);
|
|
281
|
+
const groups = groupsOf.get(id);
|
|
282
|
+
// A parent whose only children are one stacked run sits at the run's left
|
|
283
|
+
// edge, with the spine dropping straight out of it.
|
|
284
|
+
const onlyStack = groups.length === 1 && groups[0].stack;
|
|
285
|
+
let cursor = onlyStack ? start : start + (span.get(id) - groupsSpan(groups)) / 2;
|
|
286
|
+
for (const group of groups) {
|
|
287
|
+
if (group.stack) {
|
|
288
|
+
group.spine = cursor + group.spineOffset;
|
|
289
|
+
let y = bandStart[node.depth + 1];
|
|
290
|
+
for (const child of group.stack) {
|
|
291
|
+
const leaf = measured.get(child);
|
|
292
|
+
placed.set(child, { ...leaf, x: Math.round(cursor + group.indent), y: Math.round(y) });
|
|
293
|
+
y += leaf.height + STACK_GAP;
|
|
294
|
+
}
|
|
295
|
+
} else place(group.single, cursor);
|
|
296
|
+
cursor += groupSpan(group) + breadthGap;
|
|
297
|
+
}
|
|
298
|
+
let center = groups.length ? (attachOf(groups[0]) + attachOf(groups.at(-1))) / 2 : start + span.get(id) / 2;
|
|
299
|
+
// A middle child a few px off the midpoint would put a small jog in the
|
|
300
|
+
// stem; the parent sits exactly over it instead.
|
|
301
|
+
const half = breadthSize(node) / 2;
|
|
302
|
+
const middle = groups.slice(1, -1).map(attachOf)
|
|
303
|
+
.find((attach) => Math.abs(attach - center) < 24 && attach - half >= start && attach + half <= start + span.get(id));
|
|
304
|
+
if (middle !== undefined) center = middle;
|
|
305
|
+
const breadth = onlyStack ? start : center - breadthSize(node) / 2;
|
|
306
|
+
// A row going down centres each node in its band; a column going right
|
|
307
|
+
// left-aligns them so every link into the column has the same stub.
|
|
308
|
+
const depth = bandStart[node.depth] + (down ? (bandSize[node.depth] - depthSize(node)) / 2 : 0);
|
|
309
|
+
const [x, y] = down ? [breadth, depth] : [depth, breadth];
|
|
310
|
+
placed.set(id, { ...node, x: Math.round(x), y: Math.round(y), toggleAt: onlyStack ? Math.round(groups[0].spine) : null });
|
|
311
|
+
}(authoredRoot.id, down ? layout.margin : layout.top));
|
|
312
|
+
|
|
313
|
+
function centerOf(id) {
|
|
314
|
+
const node = placed.get(id);
|
|
315
|
+
return down ? node.x + node.width / 2 : node.y + node.height / 2;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const all = asArray(tree.nodes).map((node) => placed.get(node.id));
|
|
319
|
+
const viewBox = Array.isArray(tree.meta?.viewBox) ? tree.meta.viewBox : [
|
|
320
|
+
Math.max(320, Math.ceil(Math.max(...all.map((node) => node.x + node.width)) + layout.margin + (down ? 0 : layout.toggleR + 4))),
|
|
321
|
+
Math.max(240, Math.ceil(Math.max(...all.map((node) => node.y + node.height)) + layout.margin + (down ? layout.toggleR + 4 : 0))),
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
// ---- Edges ---------------------------------------------------------------------
|
|
325
|
+
// One elbow per parent-child link: out of the parent's toggle side, across at
|
|
326
|
+
// the midpoint of the generation gap, and into the child. Siblings share the
|
|
327
|
+
// stem and the crossbar, which is how a containment tree is read. A stacked
|
|
328
|
+
// child is reached along its run's spine and enters from the left.
|
|
329
|
+
function edgeStart(parent) {
|
|
330
|
+
if (!down) return [parent.x + parent.width, parent.y + parent.height / 2];
|
|
331
|
+
return [parent.toggleAt ?? parent.x + parent.width / 2, parent.y + parent.height];
|
|
332
|
+
}
|
|
333
|
+
function edgePoints(parent, child) {
|
|
334
|
+
const start = edgeStart(parent);
|
|
335
|
+
if (down && child.stacked) {
|
|
336
|
+
const spine = stackOf.get(child.id).spine;
|
|
337
|
+
const end = [child.x, child.y + child.height / 2];
|
|
338
|
+
if (parent.toggleAt !== null) return [start, [spine, end[1]], end];
|
|
339
|
+
const midY = bandStart[child.depth] - depthGap / 2;
|
|
340
|
+
return [start, [start[0], midY], [spine, midY], [spine, end[1]], end];
|
|
341
|
+
}
|
|
342
|
+
if (down) {
|
|
343
|
+
const end = [child.x + child.width / 2, child.y];
|
|
344
|
+
const midY = bandStart[child.depth] - depthGap / 2;
|
|
345
|
+
// Rounded parent/child centres can differ by ~1px and draw a useless
|
|
346
|
+
// elbow. Snap near-aligned stems to a shared x so they stay straight.
|
|
347
|
+
if (Math.abs(start[0] - end[0]) < 2) {
|
|
348
|
+
const x = Math.round((start[0] + end[0]) / 2);
|
|
349
|
+
return [[x, start[1]], [x, end[1]]];
|
|
350
|
+
}
|
|
351
|
+
return [start, [start[0], midY], [end[0], midY], end];
|
|
352
|
+
}
|
|
353
|
+
const end = [child.x, child.y + child.height / 2];
|
|
354
|
+
const midX = bandStart[child.depth] - depthGap / 2;
|
|
355
|
+
if (Math.abs(start[1] - end[1]) < 2) {
|
|
356
|
+
const y = Math.round((start[1] + end[1]) / 2);
|
|
357
|
+
return [[start[0], y], [end[0], y]];
|
|
358
|
+
}
|
|
359
|
+
return [start, [midX, start[1]], [midX, end[1]], end];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const edges = all.filter((node) => node.parent !== undefined).map((node, index) => ({
|
|
363
|
+
from: node.parent,
|
|
364
|
+
to: node.id,
|
|
365
|
+
index,
|
|
366
|
+
points: edgePoints(placed.get(node.parent), node),
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
function ancestorsOf(id) {
|
|
370
|
+
const chain = [];
|
|
371
|
+
for (let current = placed.get(id); current?.parent !== undefined; current = placed.get(current.parent)) chain.push(current.parent);
|
|
372
|
+
return chain;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function descendantCount(id) {
|
|
376
|
+
return childrenOf.get(id).reduce((sum, child) => sum + 1 + descendantCount(child), 0);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- Canvas checks -------------------------------------------------------------
|
|
380
|
+
{
|
|
381
|
+
const problems = [];
|
|
382
|
+
if (Array.isArray(tree.meta?.viewBox)) {
|
|
383
|
+
for (const node of all) {
|
|
384
|
+
if (node.x + node.width + layout.margin / 2 > viewBox[0] || node.y + node.height + layout.margin / 2 > viewBox[1]) {
|
|
385
|
+
problems.push(`Node "${node.id}" lies outside the authored meta.viewBox ${viewBox.join('x')} — remove meta.viewBox or enlarge it.`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (problems.length) throwDiagnosticProblems('Tree layout validation failed', problems, { code: 'layout/constraint', subject: { diagramType: 'tree' } });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildLayoutReport() {
|
|
393
|
+
return {
|
|
394
|
+
diagram_type: 'tree',
|
|
395
|
+
direction,
|
|
396
|
+
viewBox,
|
|
397
|
+
nodes: all.map((node) => ({ id: node.id, parent: node.parent ?? null, depth: node.depth, x: node.x, y: node.y, width: node.width, height: node.height, lines: node.labelLines.length, stacked: node.stacked })),
|
|
398
|
+
edges: edges.map((edge) => ({ from: edge.from, to: edge.to, points: edge.points })),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---- Rendering -----------------------------------------------------------------
|
|
403
|
+
function treeAttrs(id) {
|
|
404
|
+
const ancestors = ancestorsOf(id);
|
|
405
|
+
return ancestors.length ? ` data-tree-ancestors="${esc(ancestors.join(' '))}"` : '';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Each top-level branch owns one hue that its whole subtree inherits, so a
|
|
409
|
+
// reader can tell which capability a deep leaf belongs to without tracing
|
|
410
|
+
// lines. Hue is paint only: role (root, branch, leaf) stays in the passport.
|
|
411
|
+
const SUBTREE_HUES = ['backend', 'database', 'cloud', 'frontend', 'messagebus', 'external'];
|
|
412
|
+
const hueOf = new Map();
|
|
413
|
+
childrenOf.get(authoredRoot.id).forEach((id, index) => {
|
|
414
|
+
const hue = SUBTREE_HUES[index % SUBTREE_HUES.length];
|
|
415
|
+
(function paint(current) {
|
|
416
|
+
hueOf.set(current, hue);
|
|
417
|
+
for (const child of childrenOf.get(current)) paint(child);
|
|
418
|
+
}(id));
|
|
419
|
+
});
|
|
420
|
+
const hueClass = (id) => (hueOf.has(id) ? `tree-hue-${hueOf.get(id)}` : 'tree-hue-root');
|
|
421
|
+
|
|
422
|
+
function renderNode(node) {
|
|
423
|
+
const leaf = !childrenOf.get(node.id).length;
|
|
424
|
+
const tone = node.isRoot ? 'frontend' : leaf ? 'external' : 'backend';
|
|
425
|
+
const role = node.isRoot ? 'tree-root' : leaf ? 'tree-leaf' : 'tree-branch';
|
|
426
|
+
const context = i18nText(locale, node.isRoot ? 'node.context.tree.root' : childrenOf.get(node.id).length ? 'node.context.tree.branch' : 'node.context.tree.leaf');
|
|
427
|
+
const passport = { kind: tone, sublabel: node.sublabel, context };
|
|
428
|
+
// A stacked list reads left-aligned, like a directory listing; a node in a
|
|
429
|
+
// row is centred under its link.
|
|
430
|
+
const cx = node.stacked ? node.x + layout.padX + 2 : node.x + node.width / 2;
|
|
431
|
+
const anchor = node.stacked ? 'start' : 'middle';
|
|
432
|
+
let y = node.y + node.evidenceHeader + (node.height - node.evidenceHeader - node.contentHeight) / 2;
|
|
433
|
+
const label = node.labelLines.map((line) => {
|
|
434
|
+
y += layout.labelLine;
|
|
435
|
+
return `<tspan x="${cx}" y="${y - 4.5}">${esc(line)}</tspan>`;
|
|
436
|
+
}).join('');
|
|
437
|
+
y += node.sublabelLines.length ? 4 : 0;
|
|
438
|
+
const sublabel = node.sublabelLines.map((line) => {
|
|
439
|
+
y += layout.sublabelLine;
|
|
440
|
+
return `<tspan x="${cx}" y="${y - 3.5}">${esc(line)}</tspan>`;
|
|
441
|
+
}).join('');
|
|
442
|
+
// A collapsed branch shows a stacked card behind it, so the hidden subtree
|
|
443
|
+
// stays visible as "there is more here" even before the badge is read.
|
|
444
|
+
const stack = childrenOf.get(node.id).length
|
|
445
|
+
? `<rect data-tree-stack="" x="${node.x + 5}" y="${node.y + 5}" width="${node.width}" height="${node.height}" rx="10" class="tree-stack-${tone}" stroke-width="1.2"/>`
|
|
446
|
+
: '';
|
|
447
|
+
// A listed entry carries its subtree hue as a spine; every other node shows
|
|
448
|
+
// it where its link lands, as a socket on the edge it is entered from.
|
|
449
|
+
const accent = leaf && node.stacked
|
|
450
|
+
? `<path class="tree-accent" d="M ${node.x + 4.5} ${node.y + 9} V ${node.y + node.height - 9}" stroke-width="3" stroke-linecap="round" fill="none"/>`
|
|
451
|
+
: '';
|
|
452
|
+
const [sx, sy] = node.stacked || !down ? [node.x, node.y + node.height / 2] : [node.x + node.width / 2, node.y];
|
|
453
|
+
const socket = node.isRoot ? '' : `<circle cx="${sx}" cy="${sy}" r="2.6" class="tree-socket" stroke-width="1.4"/>`;
|
|
454
|
+
return ` <g ${focusNodeAttrs(node.id, node.label, passport, locale)} data-tree-depth="${node.depth}"${treeAttrs(node.id)} class="${hueClass(node.id)}">
|
|
455
|
+
${focusNodeTitle(node.label, passport)}
|
|
456
|
+
${stack}
|
|
457
|
+
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="10" class="c-mask"/>
|
|
458
|
+
<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="10" class="c-${tone} ${role}"${animateAttr(tree.meta, 'node', node.depth)} stroke-width="${node.isRoot ? 0 : leaf ? 1 : 1.2}"/>
|
|
459
|
+
${accent}
|
|
460
|
+
${socket}
|
|
461
|
+
<text data-node-label="" x="${cx}" class="t-primary tree-label" font-size="${node.labelFont}" font-weight="${node.isRoot ? 750 : leaf ? 560 : 700}" text-anchor="${anchor}">${label}</text>
|
|
462
|
+
${sublabel ? `<text data-detail="context" x="${cx}" class="t-muted tree-sublabel" font-size="${layout.sublabelFont}" text-anchor="${anchor}">${sublabel}</text>` : ''}
|
|
463
|
+
</g>`.replace(/[ \t]+$/gm, '');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// The toggle is a separate control after its node, not nested inside it, so a
|
|
467
|
+
// keyboard reader meets "node, then its branch control" and each has one role.
|
|
468
|
+
function renderToggle(node) {
|
|
469
|
+
const children = childrenOf.get(node.id);
|
|
470
|
+
if (!children.length) return '';
|
|
471
|
+
const [cx, cy] = edgeStart(node);
|
|
472
|
+
const hidden = descendantCount(node.id);
|
|
473
|
+
const collapse = i18nText(locale, 'tree.toggle.collapse', { label: node.label, count: hidden });
|
|
474
|
+
const expand = i18nText(locale, 'tree.toggle.expand', { label: node.label, count: hidden });
|
|
475
|
+
const r = layout.toggleR;
|
|
476
|
+
const arm = r * 0.42;
|
|
477
|
+
return ` <g data-tree-toggle="${esc(node.id)}" data-tree-count="${hidden}"${treeAttrs(node.id)}${node.collapsed ? ' data-tree-initially-collapsed=""' : ''} data-tree-label-collapse="${esc(collapse)}" data-tree-label-expand="${esc(expand)}" role="button" tabindex="0" aria-expanded="true" aria-label="${esc(collapse)}" class="${hueClass(node.id)}">
|
|
478
|
+
<circle cx="${cx}" cy="${cy}" r="${r + 7}" class="tree-toggle-hit"/>
|
|
479
|
+
<circle cx="${cx}" cy="${cy}" r="${r}" class="tree-toggle-disc" stroke-width="1.2"/>
|
|
480
|
+
<path class="tree-toggle-glyph" d="M ${cx - arm} ${cy} H ${cx + arm}" stroke-width="1.5" stroke-linecap="round"/>
|
|
481
|
+
<path class="tree-toggle-glyph tree-toggle-plus" d="M ${cx} ${cy - arm} V ${cy + arm}" stroke-width="1.5" stroke-linecap="round"/>
|
|
482
|
+
<g class="tree-toggle-count">
|
|
483
|
+
<rect x="${cx + r + 3}" y="${cy - 7.5}" width="${String(hidden).length * 6.2 + 11}" height="15" rx="7.5" class="tree-count-pill"/>
|
|
484
|
+
<text x="${cx + r + 3 + (String(hidden).length * 6.2 + 11) / 2}" y="${cy + 3.6}" class="t-primary" font-size="10" font-weight="700" text-anchor="middle">${hidden}</text>
|
|
485
|
+
</g>
|
|
486
|
+
</g>`.replace(/[ \t]+$/gm, '');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function renderEdge(edge) {
|
|
490
|
+
// Links out of the root share one trunk, so they stay neutral; inside a
|
|
491
|
+
// subtree every link shares its branch's hue.
|
|
492
|
+
const hued = edge.from !== authoredRoot.id;
|
|
493
|
+
const d = roundedPath(edge.points, 10);
|
|
494
|
+
return ` <path ${focusEdgeAttrs(edge.from, edge.to, undefined, edge.index)}${treeAttrs(edge.to)} data-composition-points="${routePointsValue(edge.points)}" d="${d}" class="a-default tree-edge${hued ? ` tree-edge-hued ${hueClass(edge.to)}` : ' tree-edge-trunk'}"${animateAttr(tree.meta, 'edge', placed.get(edge.to).depth)} stroke-width="${hued ? 1.5 : 1.8}"/>`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function renderSvg() {
|
|
498
|
+
const readerFit = tree.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height" data-reader-min-text="7.5"';
|
|
499
|
+
const expandAll = esc(i18nText(locale, 'tree.expandAll'));
|
|
500
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(tree.meta)} data-tree-ui="" data-tree-direction="${direction}"${readerFit}>
|
|
501
|
+
${svgAccessibleText(tree.meta, 'tree')}
|
|
502
|
+
${renderDefinitions()}
|
|
503
|
+
<style>
|
|
504
|
+
${['root', ...SUBTREE_HUES].map((hue) => hue === 'root'
|
|
505
|
+
? ' svg[data-tree-ui] .tree-hue-root { --tree-hue: var(--text-muted); --tree-wash: transparent; }'
|
|
506
|
+
: ` svg[data-tree-ui] .tree-hue-${hue} { --tree-hue: var(--${hue}-stroke); --tree-wash: var(--${hue}-fill); }`).join('\n')}
|
|
507
|
+
svg[data-tree-ui] .tree-edge { stroke-linejoin: round; stroke-linecap: round; }
|
|
508
|
+
svg[data-tree-ui] .tree-edge-trunk { stroke: color-mix(in srgb, var(--text) 18%, var(--arrow)); }
|
|
509
|
+
svg[data-tree-ui] .tree-edge-hued { stroke: color-mix(in srgb, var(--tree-hue) 42%, var(--arrow)); }
|
|
510
|
+
svg[data-tree-ui] .tree-root { fill: var(--text); stroke: none; }
|
|
511
|
+
svg[data-tree-ui] [data-tree-depth="0"] .tree-label { fill: var(--mask); }
|
|
512
|
+
svg[data-tree-ui] [data-tree-depth="0"] .tree-sublabel { fill: var(--mask); opacity: 0.68; }
|
|
513
|
+
svg[data-tree-ui] .tree-branch { fill: var(--tree-wash); stroke: color-mix(in srgb, var(--tree-hue) 48%, var(--lane-stroke)); }
|
|
514
|
+
svg[data-tree-ui] .tree-leaf { fill: var(--mask); stroke: color-mix(in srgb, var(--tree-hue) 22%, var(--lane-stroke)); }
|
|
515
|
+
svg[data-tree-ui] .tree-socket { fill: var(--mask); stroke: var(--tree-hue); pointer-events: none; }
|
|
516
|
+
svg[data-tree-ui] .tree-accent { stroke: var(--tree-hue); pointer-events: none; }
|
|
517
|
+
svg[data-tree-ui] [data-tree-stack] { display: none; fill: var(--mask); stroke: color-mix(in srgb, var(--tree-hue) 70%, var(--lane-stroke)); }
|
|
518
|
+
svg[data-tree-ui] [data-tree-collapsed] [data-tree-stack] { display: inline; }
|
|
519
|
+
svg[data-tree-ui] [data-tree-hidden] { display: none; }
|
|
520
|
+
svg[data-tree-ui] .tree-toggle-hit { fill: transparent; }
|
|
521
|
+
svg[data-tree-ui] .tree-toggle-disc { fill: var(--mask); stroke: color-mix(in srgb, var(--tree-hue) 55%, var(--lane-stroke)); transition: stroke 0.15s ease, fill 0.15s ease; }
|
|
522
|
+
svg[data-tree-ui] .tree-toggle-glyph { stroke: var(--text-muted); fill: none; transition: stroke 0.15s ease; }
|
|
523
|
+
svg[data-tree-ui] .tree-toggle-count { display: none; }
|
|
524
|
+
svg[data-tree-ui] .tree-count-pill { fill: var(--tree-hue); stroke: none; }
|
|
525
|
+
svg[data-tree-ui] .tree-toggle-count text { fill: var(--mask); }
|
|
526
|
+
svg[data-tree-ui] [data-tree-toggle] { cursor: pointer; outline: none; }
|
|
527
|
+
svg[data-tree-ui] [data-tree-toggle][aria-expanded="true"] .tree-toggle-plus { display: none; }
|
|
528
|
+
svg[data-tree-ui] [data-tree-toggle][aria-expanded="false"] .tree-toggle-count { display: inline; }
|
|
529
|
+
svg[data-tree-ui] [data-tree-toggle][aria-expanded="false"] .tree-toggle-disc { fill: var(--tree-hue); stroke: var(--tree-hue); }
|
|
530
|
+
svg[data-tree-ui] [data-tree-toggle][aria-expanded="false"] .tree-toggle-glyph { stroke: var(--mask); }
|
|
531
|
+
svg[data-tree-ui] [data-tree-toggle]:hover .tree-toggle-disc,
|
|
532
|
+
svg[data-tree-ui] [data-tree-toggle]:focus-visible .tree-toggle-disc { stroke: var(--tree-hue); stroke-width: 2; }
|
|
533
|
+
svg[data-tree-ui] [data-tree-toggle]:hover .tree-toggle-glyph,
|
|
534
|
+
svg[data-tree-ui] [data-tree-toggle]:focus-visible .tree-toggle-glyph { stroke: var(--text); }
|
|
535
|
+
svg[data-tree-ui] [data-tree-expand-all] { cursor: pointer; outline: none; }
|
|
536
|
+
svg[data-tree-ui] [data-tree-expand-all] rect { fill: var(--mask); stroke: var(--lane-stroke); }
|
|
537
|
+
svg[data-tree-ui] [data-tree-expand-all]:is(:hover, :focus-visible) rect { stroke: var(--arrow-emphasis); stroke-width: 2; }
|
|
538
|
+
svg[data-tree-ui]:not([data-tree-any-collapsed]) [data-tree-expand-all] { display: none; }
|
|
539
|
+
</style>
|
|
540
|
+
|
|
541
|
+
<!-- Background Grid -->
|
|
542
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
543
|
+
|
|
544
|
+
<!-- Containment links -->
|
|
545
|
+
${edges.map(renderEdge).join('\n')}
|
|
546
|
+
|
|
547
|
+
<!-- Nodes -->
|
|
548
|
+
${all.map(renderNode).join('\n')}
|
|
549
|
+
|
|
550
|
+
<!-- Branch toggles -->
|
|
551
|
+
${all.map(renderToggle).filter(Boolean).join('\n')}
|
|
552
|
+
|
|
553
|
+
<!-- Expand all (shown only while a branch is collapsed) -->
|
|
554
|
+
<g data-tree-expand-all="" role="button" tabindex="0" aria-label="${expandAll}">
|
|
555
|
+
<rect x="${layout.margin}" y="18" width="${Math.ceil(textUnits(expandAll) * 10.5 * ADVANCE + 24)}" height="24" rx="12" stroke-width="1.2"/>
|
|
556
|
+
<text x="${layout.margin + 12}" y="34" class="t-primary" font-size="10.5" font-weight="650">${expandAll}</text>
|
|
557
|
+
</g>
|
|
558
|
+
</svg>`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (layoutJsonMode) {
|
|
562
|
+
console.log(JSON.stringify(buildLayoutReport(), null, 2));
|
|
563
|
+
process.exit(0);
|
|
564
|
+
}
|
|
565
|
+
writeDiagram({
|
|
566
|
+
outPath,
|
|
567
|
+
template,
|
|
568
|
+
diagramType: 'tree',
|
|
569
|
+
meta: tree.meta,
|
|
570
|
+
svg: renderSvg(),
|
|
571
|
+
cards: tree.cards,
|
|
572
|
+
sourceEvidence,
|
|
573
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Execution Waterfall Renderer
|
|
2
|
+
|
|
3
|
+
Render `diagram_type: "waterfall"` JSON files into the standard Archify HTML template.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
node archify/renderers/waterfall/render-waterfall.mjs input.waterfall.json output.html
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`--layout-json` prints the axis, scale, ticks, and every row's timing and bar
|
|
10
|
+
geometry instead of HTML.
|
|
11
|
+
|
|
12
|
+
## Input
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"schema_version": 1,
|
|
17
|
+
"diagram_type": "waterfall",
|
|
18
|
+
"meta": { "title": "Checkout request", "evidence": "illustrative", "unit": "ms" },
|
|
19
|
+
"spans": [
|
|
20
|
+
{ "id": "request", "name": "POST /checkout", "start": 0, "end": 1200 },
|
|
21
|
+
{ "id": "auth", "name": "Authenticate caller", "start": 0, "duration": 100, "parent": "request" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A span states `start` and either `end` or `duration` (both only if they agree),
|
|
27
|
+
in `meta.unit` (`us`, `ms`, or `s`; default `ms`), measured from a common trace
|
|
28
|
+
origin. `parent`, `status` (`ok`, `error`, `cancelled`, `incomplete`),
|
|
29
|
+
`service`, `detail`, and `sources` are optional. `meta.evidence`
|
|
30
|
+
(`measured` / `illustrative`) is required and drawn as a badge.
|
|
31
|
+
|
|
32
|
+
| Code | Refused input |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `waterfall/invalid-timing` | end before start, end and duration that disagree, or no end/duration on a span that is not `incomplete` (negative values fail the schema) |
|
|
35
|
+
| `waterfall/missing-parent` | `parent` names an undeclared span |
|
|
36
|
+
| `waterfall/cycle` | parent links that loop |
|
|
37
|
+
| `waterfall/duplicate-id` | an id declared twice |
|
|
38
|
+
|
|
39
|
+
An `incomplete` span without an end is drawn to the last recorded instant of
|
|
40
|
+
the trace with a dashed body and an open edge, labelled `incomplete, ≥ X` — a
|
|
41
|
+
lower bound, never a measured duration. Several root spans are allowed.
|
|
42
|
+
|
|
43
|
+
## Layout
|
|
44
|
+
|
|
45
|
+
Rows are depth-first: every span is followed by its children in start order,
|
|
46
|
+
indented with tree guides in the name column, so nesting reads from the left
|
|
47
|
+
and time reads from the bar. One linear scale maps the trace from its earliest
|
|
48
|
+
start to its latest end onto `layout.width` (default 760 px); ticks use 1/2/2.5/5
|
|
49
|
+
steps. Overlapping bars on different rows are concurrent work.
|
|
50
|
+
|
|
51
|
+
A span with children is drawn as a tinted, outlined bracket over its time; a
|
|
52
|
+
span that does the work itself is a solid bar, so the paint separates inclusive
|
|
53
|
+
summaries from leaf work. Colour states the `service`: each service gets its own
|
|
54
|
+
palette family in order of first appearance (a span without one inherits its
|
|
55
|
+
parent's; unowned spans are neutral), with a dot in the name column and a legend
|
|
56
|
+
entry when more than one service appears. There are five service colours; past
|
|
57
|
+
five services they repeat, and services sharing a colour share one legend entry
|
|
58
|
+
(`gateway · inventory`), so the key never ties a colour to a single service it
|
|
59
|
+
does not own. Red is reserved for `status: "error"`.
|
|
60
|
+
|
|
61
|
+
Each bar carries its exact duration just after its end; the canvas reserves
|
|
62
|
+
room past the axis for the widest label, so short operations stay labelled. The
|
|
63
|
+
header states the wall-clock total and range. The Semantic Passport shows the
|
|
64
|
+
exact start–end, duration, and share as "N% of T wall-clock"; parent durations
|
|
65
|
+
are inclusive and are never added to their children. No critical path or
|
|
66
|
+
waiting time is drawn: the input has no dependency model to support one.
|
|
67
|
+
|
|
68
|
+
## Tested size
|
|
69
|
+
|
|
70
|
+
`examples/example-rebuild.waterfall.json` is a measured trace: 25 spans from one
|
|
71
|
+
local run that rendered and checked eight bundled examples across three
|
|
72
|
+
concurrent workers (child-process start to exit, `performance.now()` offsets).
|
|
73
|
+
It renders in about 0.2s and passes the showcase gates.
|