@bovinphang/craft-common 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/content/catalog.d.ts +4 -0
- package/dist/content/resources.d.ts +2 -0
- package/dist/content/types.d.ts +21 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +668 -0
- package/dist/install/identity.d.ts +5 -0
- package/dist/install/planner.d.ts +6 -0
- package/dist/install/transaction.d.ts +7 -0
- package/dist/install/types.d.ts +44 -0
- package/docs/consuming-common.md +33 -0
- package/localized/zh-CN/methods/debug.md +13 -0
- package/localized/zh-CN/methods/doc-sync.md +12 -0
- package/localized/zh-CN/methods/refactoring.md +16 -0
- package/localized/zh-CN/methods/source-driven-development.md +19 -0
- package/localized/zh-CN/methods/tdd.md +13 -0
- package/localized/zh-CN/skills/cc-debug-methodology/SKILL.md +8 -0
- package/localized/zh-CN/skills/cc-debug-methodology/references/method.md +13 -0
- package/localized/zh-CN/skills/cc-doc-sync-core/SKILL.md +8 -0
- package/localized/zh-CN/skills/cc-doc-sync-core/references/method.md +12 -0
- package/localized/zh-CN/skills/cc-drawio-studio/SKILL.md +73 -0
- package/localized/zh-CN/skills/cc-drawio-studio/data/THIRD_PARTY_NOTICES.md +10 -0
- package/localized/zh-CN/skills/cc-drawio-studio/data/brand-icons.json +878 -0
- package/localized/zh-CN/skills/cc-drawio-studio/data/shape-index.json +83570 -0
- package/localized/zh-CN/skills/cc-drawio-studio/references/data-residency.md +27 -0
- package/localized/zh-CN/skills/cc-drawio-studio/references/diagram-patterns.md +60 -0
- package/localized/zh-CN/skills/cc-drawio-studio/references/flowchart-quality.md +49 -0
- package/localized/zh-CN/skills/cc-drawio-studio/references/layout-and-export.md +72 -0
- package/localized/zh-CN/skills/cc-drawio-studio/references/troubleshooting.md +29 -0
- package/localized/zh-CN/skills/cc-drawio-studio/references/xml-and-mermaid.md +58 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/brand-symbols.mjs +99 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/diagram-lint.mjs +205 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/diagram-url.mjs +102 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/drawio-export.mjs +134 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/layout-graph.mjs +107 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/png-embed-fix.mjs +26 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-go-packages.mjs +36 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-js-modules.mjs +52 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-python-classes.mjs +30 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-python-modules.mjs +55 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-rust-modules.mjs +35 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-ts-modules.mjs +7 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/shape-query.mjs +121 -0
- package/localized/zh-CN/skills/cc-drawio-studio/scripts/studio-core.mjs +139 -0
- package/localized/zh-CN/skills/cc-drawio-studio/styles/built-in/corporate.json +49 -0
- package/localized/zh-CN/skills/cc-drawio-studio/styles/built-in/default.json +49 -0
- package/localized/zh-CN/skills/cc-drawio-studio/styles/built-in/handdrawn.json +49 -0
- package/localized/zh-CN/skills/cc-drawio-studio/styles/schema.json +112 -0
- package/localized/zh-CN/skills/cc-refactoring-core/SKILL.md +8 -0
- package/localized/zh-CN/skills/cc-refactoring-core/references/method.md +16 -0
- package/localized/zh-CN/skills/cc-source-driven-development/SKILL.md +8 -0
- package/localized/zh-CN/skills/cc-source-driven-development/references/method.md +19 -0
- package/localized/zh-CN/skills/cc-tdd-methodology/SKILL.md +8 -0
- package/localized/zh-CN/skills/cc-tdd-methodology/references/method.md +13 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/SKILL.md +34 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/interactive-diagram.html +371 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/README.md +14 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/architecture.json +121 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/invalid-explicit.json +41 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/sequence.json +53 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/sequence.mmd +21 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/workflow.json +98 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/workflow.mmd +7 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/references/diagram-workflows.md +81 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/references/html-technical-diagrams.md +243 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/references/png-qa-autofix.md +37 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/diagram-browser.mjs +153 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/diagram-layout.mjs +176 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/export-diagram.mjs +332 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/interactive-diagram-server.mjs +654 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/mermaid-render.mjs +64 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/png-qa.mjs +462 -0
- package/localized/zh-CN/skills/cc-technical-diagrams/scripts/tech-diagram-render.mjs +1537 -0
- package/localized/zh-CN/skills/eval_queries.json +160 -0
- package/localized/zh-CN/skills/metadata.json +223 -0
- package/localized/zh-CN/skills/relations.json +53 -0
- package/methods/debug.md +13 -0
- package/methods/doc-sync.md +12 -0
- package/methods/refactoring.md +16 -0
- package/methods/source-driven-development.md +19 -0
- package/methods/tdd.md +13 -0
- package/package.json +51 -4
- package/skills/cc-debug-methodology/SKILL.md +8 -0
- package/skills/cc-debug-methodology/references/method.md +13 -0
- package/skills/cc-doc-sync-core/SKILL.md +8 -0
- package/skills/cc-doc-sync-core/references/method.md +12 -0
- package/skills/cc-drawio-studio/SKILL.md +73 -0
- package/skills/cc-drawio-studio/data/THIRD_PARTY_NOTICES.md +10 -0
- package/skills/cc-drawio-studio/data/brand-icons.json +878 -0
- package/skills/cc-drawio-studio/data/shape-index.json +83570 -0
- package/skills/cc-drawio-studio/references/data-residency.md +27 -0
- package/skills/cc-drawio-studio/references/diagram-patterns.md +60 -0
- package/skills/cc-drawio-studio/references/flowchart-quality.md +49 -0
- package/skills/cc-drawio-studio/references/layout-and-export.md +72 -0
- package/skills/cc-drawio-studio/references/troubleshooting.md +29 -0
- package/skills/cc-drawio-studio/references/xml-and-mermaid.md +58 -0
- package/skills/cc-drawio-studio/scripts/brand-symbols.mjs +99 -0
- package/skills/cc-drawio-studio/scripts/diagram-lint.mjs +205 -0
- package/skills/cc-drawio-studio/scripts/diagram-url.mjs +102 -0
- package/skills/cc-drawio-studio/scripts/drawio-export.mjs +134 -0
- package/skills/cc-drawio-studio/scripts/layout-graph.mjs +107 -0
- package/skills/cc-drawio-studio/scripts/png-embed-fix.mjs +26 -0
- package/skills/cc-drawio-studio/scripts/scan-go-packages.mjs +36 -0
- package/skills/cc-drawio-studio/scripts/scan-js-modules.mjs +52 -0
- package/skills/cc-drawio-studio/scripts/scan-python-classes.mjs +30 -0
- package/skills/cc-drawio-studio/scripts/scan-python-modules.mjs +55 -0
- package/skills/cc-drawio-studio/scripts/scan-rust-modules.mjs +35 -0
- package/skills/cc-drawio-studio/scripts/scan-ts-modules.mjs +7 -0
- package/skills/cc-drawio-studio/scripts/shape-query.mjs +121 -0
- package/skills/cc-drawio-studio/scripts/studio-core.mjs +139 -0
- package/skills/cc-drawio-studio/styles/built-in/corporate.json +49 -0
- package/skills/cc-drawio-studio/styles/built-in/default.json +49 -0
- package/skills/cc-drawio-studio/styles/built-in/handdrawn.json +49 -0
- package/skills/cc-drawio-studio/styles/schema.json +112 -0
- package/skills/cc-refactoring-core/SKILL.md +8 -0
- package/skills/cc-refactoring-core/references/method.md +16 -0
- package/skills/cc-source-driven-development/SKILL.md +8 -0
- package/skills/cc-source-driven-development/references/method.md +19 -0
- package/skills/cc-tdd-methodology/SKILL.md +8 -0
- package/skills/cc-tdd-methodology/references/method.md +13 -0
- package/skills/cc-technical-diagrams/SKILL.md +34 -0
- package/skills/cc-technical-diagrams/assets/interactive-diagram.html +371 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/README.md +14 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/architecture.json +121 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/invalid-explicit.json +41 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/sequence.json +53 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/sequence.mmd +21 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/workflow.json +98 -0
- package/skills/cc-technical-diagrams/assets/quality-examples/workflow.mmd +7 -0
- package/skills/cc-technical-diagrams/references/diagram-workflows.md +81 -0
- package/skills/cc-technical-diagrams/references/html-technical-diagrams.md +243 -0
- package/skills/cc-technical-diagrams/references/png-qa-autofix.md +37 -0
- package/skills/cc-technical-diagrams/scripts/diagram-browser.mjs +153 -0
- package/skills/cc-technical-diagrams/scripts/diagram-layout.mjs +176 -0
- package/skills/cc-technical-diagrams/scripts/export-diagram.mjs +332 -0
- package/skills/cc-technical-diagrams/scripts/interactive-diagram-server.mjs +654 -0
- package/skills/cc-technical-diagrams/scripts/mermaid-render.mjs +64 -0
- package/skills/cc-technical-diagrams/scripts/png-qa.mjs +462 -0
- package/skills/cc-technical-diagrams/scripts/tech-diagram-render.mjs +1537 -0
- package/skills/eval_queries.json +160 -0
- package/skills/metadata.json +223 -0
- package/skills/relations.json +53 -0
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { layoutText, fitNodeText, routeConnector, placeEdgeLabel, overlaps } from "./diagram-layout.mjs";
|
|
7
|
+
|
|
8
|
+
/** @type {import("./diagram-layout.mjs").QualityIssue[]} */
|
|
9
|
+
const qualityIssues = [];
|
|
10
|
+
/** @type {Array<{id:string,owner:string,kind:string,text:string,lines:string[],x:number,y:number,width:number,height:number}>} */
|
|
11
|
+
const textBounds = [];
|
|
12
|
+
/** @type {Box[]} */
|
|
13
|
+
let routeObstacles = [];
|
|
14
|
+
/** @type {Array<Array<[number,number]>>} */
|
|
15
|
+
const occupiedRoutes = [];
|
|
16
|
+
/** @type {Array<{id:string,label:string,points:Array<[number,number]>}>} */
|
|
17
|
+
const deferredLabels=[];
|
|
18
|
+
let finalCanvas = { width: 0, height: 0 };
|
|
19
|
+
|
|
20
|
+
const ALLOWED_TYPES = new Set(["workflow", "sequence", "dataflow", "lifecycle", "architecture"]);
|
|
21
|
+
const NODE_TYPES = new Set([
|
|
22
|
+
"frontend", "backend", "database", "cloud", "security", "messagebus", "external", "active", "waiting", "decision", "success", "failure", "neutral", "start",
|
|
23
|
+
"agent", "model", "memory", "vectorstore", "graphdb", "tool", "document", "queue", "browser", "user", "gateway",
|
|
24
|
+
]);
|
|
25
|
+
const EDGE_VARIANTS = new Set(["default", "emphasis", "security", "dashed", "return"]);
|
|
26
|
+
const VISUAL_STYLES = new Set(["default", "terminal", "blueprint", "minimal", "glass", "warm", "openai", "editorial-dark"]);
|
|
27
|
+
const FLOW_TYPES = new Set(["control", "data", "read", "write", "async", "feedback"]);
|
|
28
|
+
|
|
29
|
+
const args = parseArgs(process.argv.slice(2));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const inputPath = requireArg(args, "input");
|
|
33
|
+
const outputPath = requireArg(args, "output");
|
|
34
|
+
const type = requireArg(args, "type");
|
|
35
|
+
const format = args.format ?? "markdown";
|
|
36
|
+
|
|
37
|
+
if (!ALLOWED_TYPES.has(type)) {
|
|
38
|
+
throw usageError("type", `Unsupported diagram type "${type}". Expected workflow, sequence, dataflow, lifecycle, or architecture.`);
|
|
39
|
+
}
|
|
40
|
+
if (format !== "json" && format !== "markdown") {
|
|
41
|
+
throw usageError("format", `Unsupported report format "${format}". Expected json or markdown.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const diagram = readJson(inputPath);
|
|
45
|
+
validateCommon(diagram, type);
|
|
46
|
+
const renderResult = renderDiagram(diagram, type);
|
|
47
|
+
/** @type {SummaryCard[]} */
|
|
48
|
+
const summary = "summary" in renderResult ? /** @type {SummaryCard[]} */ (renderResult.summary) : [];
|
|
49
|
+
const style = normalizeVisualStyle(diagram.visual, "/visual");
|
|
50
|
+
const rawHtml = renderHtml(diagram.meta.title, diagram.meta.subtitle ?? "", renderResult.svg, type, summary, style);
|
|
51
|
+
|
|
52
|
+
const pageStyles = rawHtml.match(/<style>([\s\S]*?)<\/style>/)?.[1] ?? "";
|
|
53
|
+
const diagramStyles=(pageStyles.match(/[^{}]+\{[^{}]*\}/g) ?? []).filter(rule=>/^\s*(?:[:][r]oot|\[data-|\.node|\.edge|\.group|\.lane|\.legend|\.flow|\.label|\.sublabel|\.muted|\.actor|\.step|\.lifeline|\.tech-diagram|\.surface|\.stage|marker|#arrow)/.test(rule)).join("\n");
|
|
54
|
+
const svgStyles = diagramStyles.replaceAll(":root", "svg").replaceAll(/\[data-theme=/g, "svg[data-theme=").replaceAll(/\[data-visual-style=/g, "svg[data-visual-style=");
|
|
55
|
+
const html = rawHtml.replace('data-visual-style="default" data-export-root', `data-visual-style="${style}" data-export-root`).replace(/(<svg[^>]*>)/, `$1<style>${svgStyles}\nsvg{font-family:ui-sans-serif,system-ui,"Segoe UI",sans-serif;}svg[data-theme="dark"]{--surface:#172033;--text:#e2e8f0;--muted:#94a3b8;--line:#94a3b8;}svg[data-theme="light"]{--surface:#f8fafc;--text:#0f172a;--muted:#475569;--line:#475569;--frontend:#0369a1;--backend:#047857;--database:#0e7490;--external:#475569;--agent:#0e7490;--model:#7e22ce;--memory:#047857;--gateway:#a16207;--browser:#0369a1;--queue:#c2410c;--flow-control:#b45309;--flow-data:#0369a1;--flow-read:#047857;--flow-write:#15803d;--flow-async:#64748b;--flow-feedback:#7e22ce;}</style>`);
|
|
56
|
+
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
57
|
+
fs.writeFileSync(outputPath, html, "utf8");
|
|
58
|
+
|
|
59
|
+
if (args.manifest) {
|
|
60
|
+
fs.mkdirSync(path.dirname(path.resolve(args.manifest)), { recursive: true });
|
|
61
|
+
fs.writeFileSync(args.manifest, `${JSON.stringify(renderResult.manifest, null, 2)}\n`, "utf8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const report = {
|
|
65
|
+
ok: true,
|
|
66
|
+
type,
|
|
67
|
+
input: inputPath,
|
|
68
|
+
output: outputPath,
|
|
69
|
+
manifest: args.manifest ?? null,
|
|
70
|
+
quality: { status: qualityIssues.length ? "partial" : "estimated", issues: qualityIssues },
|
|
71
|
+
nodes: renderResult.manifest.boxes.length,
|
|
72
|
+
connectors: renderResult.manifest.connectors.length,
|
|
73
|
+
};
|
|
74
|
+
printReport(report, format);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
+
if (args.format === "json") {
|
|
78
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
79
|
+
} else {
|
|
80
|
+
console.error(message);
|
|
81
|
+
}
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string[]} argv
|
|
87
|
+
* @returns {Record<string, string | undefined>}
|
|
88
|
+
*/
|
|
89
|
+
function parseArgs(argv) {
|
|
90
|
+
/** @type {Record<string, string | undefined>} */
|
|
91
|
+
const parsed = {};
|
|
92
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
93
|
+
const token = argv[index];
|
|
94
|
+
if (!token.startsWith("--")) {
|
|
95
|
+
throw usageError("argv", `Unexpected positional argument "${token}".`);
|
|
96
|
+
}
|
|
97
|
+
const key = token.slice(2);
|
|
98
|
+
const value = argv[index + 1];
|
|
99
|
+
if (!value || value.startsWith("--")) {
|
|
100
|
+
throw usageError(key, `Missing value for --${key}.`);
|
|
101
|
+
}
|
|
102
|
+
parsed[key] = value;
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {Record<string, string | undefined>} parsed
|
|
110
|
+
* @param {string} key
|
|
111
|
+
*/
|
|
112
|
+
function requireArg(parsed, key) {
|
|
113
|
+
const value = parsed[key];
|
|
114
|
+
if (!value) throw usageError(key, `Missing required --${key}.`);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} filePath
|
|
120
|
+
*/
|
|
121
|
+
function readJson(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw usageError("input", `Invalid JSON at /: ${error instanceof Error ? error.message : String(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {unknown} diagram
|
|
131
|
+
* @param {string} type
|
|
132
|
+
*/
|
|
133
|
+
function validateCommon(diagram, type) {
|
|
134
|
+
assertObject(diagram, "/");
|
|
135
|
+
const model = /** @type {Record<string, unknown>} */ (diagram);
|
|
136
|
+
if (model.schema_version !== 1) {
|
|
137
|
+
throw usageError("/schema_version", "Expected schema_version to equal 1.");
|
|
138
|
+
}
|
|
139
|
+
if (model.diagram_type !== type) {
|
|
140
|
+
throw usageError("/diagram_type", `Expected diagram_type to equal "${type}".`);
|
|
141
|
+
}
|
|
142
|
+
assertObject(model.meta, "/meta");
|
|
143
|
+
requireString(/** @type {Record<string, unknown>} */ (model.meta), "title", "/meta/title");
|
|
144
|
+
normalizeVisualStyle(model.visual, "/visual");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {unknown} diagram
|
|
149
|
+
* @param {string} type
|
|
150
|
+
*/
|
|
151
|
+
function renderDiagram(diagram, type) {
|
|
152
|
+
if (type === "workflow") return renderLaneDiagram(/** @type {LaneDiagram} */ (diagram), "nodes", "edges");
|
|
153
|
+
if (type === "lifecycle") return renderLaneDiagram(/** @type {LaneDiagram} */ (diagram), "states", "transitions");
|
|
154
|
+
if (type === "sequence") return renderSequence(/** @type {SequenceDiagram} */ (diagram));
|
|
155
|
+
if (type === "architecture") return renderArchitecture(/** @type {ArchitectureDiagram} */ (diagram));
|
|
156
|
+
return renderDataflow(/** @type {DataflowDiagram} */ (diagram));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {ArchitectureDiagram} diagram
|
|
161
|
+
*/
|
|
162
|
+
function renderArchitecture(diagram) {
|
|
163
|
+
const nodes = requireObjectArray(diagram.nodes, "/nodes");
|
|
164
|
+
const connections = requireObjectArray(diagram.connections, "/connections");
|
|
165
|
+
const groups = diagram.groups === undefined ? [] : requireObjectArray(diagram.groups, "/groups");
|
|
166
|
+
const groupIds = new Set();
|
|
167
|
+
/** @type {Map<string, Box>} */
|
|
168
|
+
const boxes = new Map();
|
|
169
|
+
/** @type {Box[]} */
|
|
170
|
+
const groupBoxes = [];
|
|
171
|
+
|
|
172
|
+
for (const [index, group] of groups.entries()) {
|
|
173
|
+
const id = requireId(group, "id", `/groups/${index}/id`);
|
|
174
|
+
ensureUnique(groupIds, id, `/groups/${index}/id`);
|
|
175
|
+
const label = requireString(group, "label", `/groups/${index}/label`);
|
|
176
|
+
const type = normalizeType(group.type, `/groups/${index}/type`);
|
|
177
|
+
const box = {
|
|
178
|
+
id,
|
|
179
|
+
x: requireNumber(group, "x", `/groups/${index}/x`),
|
|
180
|
+
y: requireNumber(group, "y", `/groups/${index}/y`),
|
|
181
|
+
width: requireNumber(group, "width", `/groups/${index}/width`),
|
|
182
|
+
height: requireNumber(group, "height", `/groups/${index}/height`),
|
|
183
|
+
label,
|
|
184
|
+
sublabel: "",
|
|
185
|
+
type,
|
|
186
|
+
kind: "group",
|
|
187
|
+
};
|
|
188
|
+
groupBoxes.push(box);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const [index, node] of nodes.entries()) {
|
|
192
|
+
const id = requireId(node, "id", `/nodes/${index}/id`);
|
|
193
|
+
ensureUnique(boxes, id, `/nodes/${index}/id`);
|
|
194
|
+
if (typeof node.group === "string" && !groupIds.has(node.group)) {
|
|
195
|
+
throw usageError(`/nodes/${index}/group`, `Unknown group "${node.group}".`);
|
|
196
|
+
}
|
|
197
|
+
boxes.set(id, {
|
|
198
|
+
id,
|
|
199
|
+
x: requireNumber(node, "x", `/nodes/${index}/x`),
|
|
200
|
+
y: requireNumber(node, "y", `/nodes/${index}/y`),
|
|
201
|
+
width: typeof node.width === "number" ? node.width : 132,
|
|
202
|
+
height: typeof node.height === "number" ? node.height : 62,
|
|
203
|
+
label: requireString(node, "label", `/nodes/${index}/label`),
|
|
204
|
+
sublabel: typeof node.sublabel === "string" ? node.sublabel : "",
|
|
205
|
+
type: normalizeType(node.type, `/nodes/${index}/type`),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
prepareBoxes(boxes, nodes);
|
|
210
|
+
for (const node of nodes) {
|
|
211
|
+
const box = boxes.get(String(node.id));
|
|
212
|
+
const group = groupBoxes.find((candidate) => candidate.id === node.group);
|
|
213
|
+
if (box && group && (box.x < group.x + 12 || box.y < group.y + 32 || box.x + box.width > group.x + group.width - 12 || box.y + box.height > group.y + group.height - 12)) qualityIssues.push({code:"group-containment",id:box.id,message:`Node "${box.id}" exceeds group "${group.id}" content area.`});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
/** @type {Connector[]} */
|
|
218
|
+
const connectors = [];
|
|
219
|
+
const flowSet = new Set();
|
|
220
|
+
const connectionSvg = connections.map((connection, index) => {
|
|
221
|
+
const from = requireId(connection, "from", `/connections/${index}/from`);
|
|
222
|
+
const to = requireId(connection, "to", `/connections/${index}/to`);
|
|
223
|
+
const fromBox = boxes.get(from);
|
|
224
|
+
const toBox = boxes.get(to);
|
|
225
|
+
if (!fromBox) throw usageError(`/connections/${index}/from`, `Unknown endpoint "${from}".`);
|
|
226
|
+
if (!toBox) throw usageError(`/connections/${index}/to`, `Unknown endpoint "${to}".`);
|
|
227
|
+
const variant = normalizeVariant(connection.variant, `/connections/${index}/variant`);
|
|
228
|
+
const flow = normalizeFlow(connection.flow, `/connections/${index}/flow`);
|
|
229
|
+
if (flow) flowSet.add(flow);
|
|
230
|
+
const points = architectureRoute(fromBox, toBox, connection.waypoints, `/connections/${index}/waypoints`);
|
|
231
|
+
connectors.push({ id: `${from}-${to}-${index}`, from, to, points });
|
|
232
|
+
return renderConnector(points, variant, typeof connection.label === "string" ? connection.label : "", flow);
|
|
233
|
+
}).join("\n");
|
|
234
|
+
|
|
235
|
+
const allBoxes = [...groupBoxes, ...boxes.values()];
|
|
236
|
+
const contentBottom = Math.max(360, maxBoxExtent(allBoxes, "y") + 64);
|
|
237
|
+
const legend = normalizeLegend(diagram.legend, [...boxes.values()]);
|
|
238
|
+
const flowLegend = normalizeFlowLegend(diagram.flowLegend, [...flowSet]);
|
|
239
|
+
const width = Math.max(860, maxBoxExtent(allBoxes, "x") + 64);
|
|
240
|
+
const columns=Math.max(1,Math.floor((width-88)/200));
|
|
241
|
+
const legendHeight=legend.length ? 30+Math.ceil(legend.length/columns)*72 : 0;
|
|
242
|
+
const flowHeight=flowLegend.length ? 30+Math.ceil(flowLegend.length/columns)*72 : 0;
|
|
243
|
+
const legendSvg = renderArchitectureLegend(legend,44,contentBottom-4,columns);
|
|
244
|
+
const flowLegendSvg = renderFlowLegend(flowLegend,44,contentBottom+legendHeight,columns);
|
|
245
|
+
const height=contentBottom+legendHeight+flowHeight+32;
|
|
246
|
+
const groupSvg = groupBoxes.map(renderGroup).join("\n");
|
|
247
|
+
const boxSvg = [...boxes.values()].map(renderBox).join("\n");
|
|
248
|
+
const svg = wrapSvg(width, height, `${groupSvg}\n${connectionSvg}\n${boxSvg}\n${legendSvg}\n${flowLegendSvg}`);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
svg,
|
|
252
|
+
manifest: manifestFrom(width, height, allBoxes, connectors),
|
|
253
|
+
summary: normalizeSummary(diagram.summary),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {LaneDiagram} diagram
|
|
259
|
+
* @param {"nodes" | "states"} itemKey
|
|
260
|
+
* @param {"edges" | "transitions"} edgeKey
|
|
261
|
+
*/
|
|
262
|
+
function renderLaneDiagram(diagram, itemKey, edgeKey) {
|
|
263
|
+
const lanes = requireObjectArray(diagram.lanes, "/lanes");
|
|
264
|
+
const items = requireObjectArray(diagram[itemKey], `/${itemKey}`);
|
|
265
|
+
const edges = requireObjectArray(diagram[edgeKey], `/${edgeKey}`);
|
|
266
|
+
const isWorkflow = itemKey === "nodes";
|
|
267
|
+
const laneIds = new Set();
|
|
268
|
+
for (const [index, lane] of lanes.entries()) {
|
|
269
|
+
assertObject(lane, `/lanes/${index}`);
|
|
270
|
+
const id = requireId(lane, "id", `/lanes/${index}/id`);
|
|
271
|
+
requireString(lane, "label", `/lanes/${index}/label`);
|
|
272
|
+
ensureUnique(laneIds, id, `/lanes/${index}/id`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** @type {Map<string, Box>} */
|
|
276
|
+
const boxes = new Map();
|
|
277
|
+
const maxCol = Math.max(0, ...items.map((item, index) => {
|
|
278
|
+
assertObject(item, `/${itemKey}/${index}`);
|
|
279
|
+
return requireNumber(item, "col", `/${itemKey}/${index}/col`);
|
|
280
|
+
}));
|
|
281
|
+
let width = Math.max(780, 190 + (maxCol + 1) * 180);
|
|
282
|
+
let height = Math.max(420, 140 + lanes.length * 145);
|
|
283
|
+
const laneHeight = 118;
|
|
284
|
+
const top = 72;
|
|
285
|
+
const left = 142;
|
|
286
|
+
const colGap = 172;
|
|
287
|
+
|
|
288
|
+
const laneIndex = new Map(lanes.map((lane, index) => [String(lane.id), index]));
|
|
289
|
+
for (const [index, item] of items.entries()) {
|
|
290
|
+
const id = requireId(item, "id", `/${itemKey}/${index}/id`);
|
|
291
|
+
ensureUnique(boxes, id, `/${itemKey}/${index}/id`);
|
|
292
|
+
const lane = requireId(item, "lane", `/${itemKey}/${index}/lane`);
|
|
293
|
+
if (!laneIds.has(lane)) throw usageError(`/${itemKey}/${index}/lane`, `Unknown lane "${lane}".`);
|
|
294
|
+
const type = normalizeType(item.type, `/${itemKey}/${index}/type`);
|
|
295
|
+
const label = requireString(item, "label", `/${itemKey}/${index}/label`);
|
|
296
|
+
const sublabel = typeof item.sublabel === "string" ? item.sublabel : "";
|
|
297
|
+
const col = requireNumber(item, "col", `/${itemKey}/${index}/col`);
|
|
298
|
+
const yOffset = typeof item.yOffset === "number" ? item.yOffset : 0;
|
|
299
|
+
const width = typeof item.width === "number" ? item.width : defaultWorkflowWidth(type, isWorkflow);
|
|
300
|
+
const height = typeof item.height === "number" ? item.height : defaultWorkflowHeight(type, isWorkflow);
|
|
301
|
+
const box = {
|
|
302
|
+
id,
|
|
303
|
+
x: left + col * colGap,
|
|
304
|
+
y: top + Number(laneIndex.get(lane)) * laneHeight + 38 + yOffset,
|
|
305
|
+
width,
|
|
306
|
+
height,
|
|
307
|
+
label,
|
|
308
|
+
sublabel,
|
|
309
|
+
type,
|
|
310
|
+
actor: isWorkflow && typeof item.actor === "string" ? item.actor : "",
|
|
311
|
+
step: isWorkflow ? normalizeStep(item.step, index + 1, `/${itemKey}/${index}/step`) : "",
|
|
312
|
+
shape: isWorkflow ? shapeForWorkflowType(type) : "box",
|
|
313
|
+
};
|
|
314
|
+
boxes.set(id, box);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
prepareBoxes(boxes, items);
|
|
318
|
+
let laneTop = 72;
|
|
319
|
+
/** @type {Array<{y:number,height:number}>} */
|
|
320
|
+
const laneGeometry = [];
|
|
321
|
+
const columns = [...new Set(items.map((item) => Number(item.col)))].sort((a, b) => a - b);
|
|
322
|
+
const columnX = new Map();
|
|
323
|
+
let nextX = 160;
|
|
324
|
+
for (const col of columns) {
|
|
325
|
+
columnX.set(col, nextX);
|
|
326
|
+
nextX += Math.max(0, ...items.filter((item) => item.col === col).map((item) => boxes.get(String(item.id))?.width ?? 0)) + 64;
|
|
327
|
+
}
|
|
328
|
+
for (const lane of lanes) {
|
|
329
|
+
const members = items.filter((item) => item.lane === lane.id);
|
|
330
|
+
const columnBottom = new Map();
|
|
331
|
+
for (const item of members) {
|
|
332
|
+
const box = boxes.get(String(item.id));
|
|
333
|
+
if (!box) continue;
|
|
334
|
+
box.x = Number(columnX.get(Number(item.col)));
|
|
335
|
+
const baseline = columnBottom.get(item.col) ?? laneTop + 48;
|
|
336
|
+
box.y = baseline + (typeof item.yOffset === "number" ? item.yOffset : 0);
|
|
337
|
+
columnBottom.set(item.col, box.y + box.height + 32);
|
|
338
|
+
}
|
|
339
|
+
const bottom = Math.max(laneTop + 112, ...members.map((item) => { const b = boxes.get(String(item.id)); return b ? b.y + b.height + 24 : laneTop; }));
|
|
340
|
+
laneGeometry.push({ y: laneTop, height: bottom - laneTop });
|
|
341
|
+
laneTop = bottom + 32;
|
|
342
|
+
}
|
|
343
|
+
width = Math.max(width, nextX + 40);
|
|
344
|
+
height = Math.max(height, laneTop + 40);
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
/** @type {Connector[]} */
|
|
348
|
+
const connectors = [];
|
|
349
|
+
const edgeSvg = edges.map((edge, index) => {
|
|
350
|
+
assertObject(edge, `/${edgeKey}/${index}`);
|
|
351
|
+
const from = requireId(edge, "from", `/${edgeKey}/${index}/from`);
|
|
352
|
+
const to = requireId(edge, "to", `/${edgeKey}/${index}/to`);
|
|
353
|
+
const fromBox = boxes.get(from);
|
|
354
|
+
const toBox = boxes.get(to);
|
|
355
|
+
if (!fromBox) throw usageError(`/${edgeKey}/${index}/from`, `Unknown endpoint "${from}".`);
|
|
356
|
+
if (!toBox) throw usageError(`/${edgeKey}/${index}/to`, `Unknown endpoint "${to}".`);
|
|
357
|
+
const variant = normalizeVariant(edge.variant, `/${edgeKey}/${index}/variant`);
|
|
358
|
+
const points = edge.waypoints === undefined ? routeBoxes(fromBox, toBox) : routeWithWaypoints(fromBox, toBox, edge.waypoints, `/${edgeKey}/${index}/waypoints`);
|
|
359
|
+
const connectorId = `${from}-${to}-${index}`;
|
|
360
|
+
connectors.push({ id: connectorId, from, to, points });
|
|
361
|
+
return renderConnector(points, variant, typeof edge.label === "string" ? edge.label : "");
|
|
362
|
+
}).join("\n");
|
|
363
|
+
|
|
364
|
+
const laneSvg = lanes.map((lane, index) => {
|
|
365
|
+
const y = laneGeometry[index].y;
|
|
366
|
+
return `<g class="lane"><rect x="34" y="${y}" width="${width - 68}" height="${laneGeometry[index].height}" rx="10"/><text x="52" y="${y + 28}" class="label muted">${escapeHtml(String(lane.label))}</text></g>`;
|
|
367
|
+
}).join("\n");
|
|
368
|
+
const boxSvg = [...boxes.values()].map(renderBox).join("\n");
|
|
369
|
+
const svg = wrapSvg(width, height, `${laneSvg}\n${edgeSvg}\n${boxSvg}`);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
svg,
|
|
373
|
+
manifest: manifestFrom(width, height, [...boxes.values()], connectors),
|
|
374
|
+
summary: isWorkflow ? normalizeSummary(diagram.summary) : [],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {SequenceDiagram} diagram
|
|
380
|
+
*/
|
|
381
|
+
function renderSequence(diagram) {
|
|
382
|
+
const participants = requireObjectArray(diagram.participants, "/participants");
|
|
383
|
+
const messages = requireObjectArray(diagram.messages, "/messages");
|
|
384
|
+
const ids = new Set();
|
|
385
|
+
let width = Math.max(760, 160 + participants.length * 170);
|
|
386
|
+
let height = Math.max(420, 170 + messages.length * 82);
|
|
387
|
+
const top = 74;
|
|
388
|
+
const laneGap = (width - 160) / Math.max(1, participants.length - 1);
|
|
389
|
+
/** @type {Map<string, Box>} */
|
|
390
|
+
const boxes = new Map();
|
|
391
|
+
|
|
392
|
+
for (const [index, participant] of participants.entries()) {
|
|
393
|
+
assertObject(participant, `/participants/${index}`);
|
|
394
|
+
const id = requireId(participant, "id", `/participants/${index}/id`);
|
|
395
|
+
ensureUnique(ids, id, `/participants/${index}/id`);
|
|
396
|
+
const type = normalizeType(participant.type, `/participants/${index}/type`);
|
|
397
|
+
const label = requireString(participant, "label", `/participants/${index}/label`);
|
|
398
|
+
const x = participants.length === 1 ? width / 2 - 64 : 80 + index * laneGap - 64;
|
|
399
|
+
boxes.set(id, {
|
|
400
|
+
id,
|
|
401
|
+
x,
|
|
402
|
+
y: top,
|
|
403
|
+
width: 128,
|
|
404
|
+
height: 56,
|
|
405
|
+
label,
|
|
406
|
+
sublabel: typeof participant.sublabel === "string" ? participant.sublabel : "",
|
|
407
|
+
type,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
prepareBoxes(boxes, participants);
|
|
412
|
+
const gap = Math.max(280, ...[...boxes.values()].map(box=>box.width+80), ...messages.map((m) => layoutText(typeof m.label === "string" ? m.label : "", 260, 12, 16).width + 60));
|
|
413
|
+
width = Math.max(width, 160 + participants.length * gap);
|
|
414
|
+
[...boxes.values()].forEach((box, index) => { box.x = 80 + index * gap + gap / 2 - box.width / 2; });
|
|
415
|
+
let messageY = Math.max(...[...boxes.values()].map((box) => box.y + box.height)) + 72;
|
|
416
|
+
height = Math.max(height, messageY + messages.length * 108 + 80);
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
/** @type {Connector[]} */
|
|
420
|
+
const connectors = [];
|
|
421
|
+
|
|
422
|
+
const messageSvg = messages.map((message, index) => {
|
|
423
|
+
assertObject(message, `/messages/${index}`);
|
|
424
|
+
if (["activation","activate","deactivate","fragment","alt","loop","par","async"].some((key)=>key in message)) throw usageError(`/messages/${index}`,"Complex sequence semantics require the Mermaid route; do not simplify activation/fragments/async fields.");
|
|
425
|
+
const from = requireId(message, "from", `/messages/${index}/from`);
|
|
426
|
+
const to = requireId(message, "to", `/messages/${index}/to`);
|
|
427
|
+
const fromBox = boxes.get(from);
|
|
428
|
+
const toBox = boxes.get(to);
|
|
429
|
+
if (!fromBox) throw usageError(`/messages/${index}/from`, `Unknown endpoint "${from}".`);
|
|
430
|
+
if (!toBox) throw usageError(`/messages/${index}/to`, `Unknown endpoint "${to}".`);
|
|
431
|
+
const messageText = typeof message.label === "string" ? message.label : "";
|
|
432
|
+
const textLayout = layoutText(messageText, 190, 12, 16);
|
|
433
|
+
const y = messageY;
|
|
434
|
+
messageY += Math.max(72, textLayout.height + 56 + (from === to ? 32 : 0));
|
|
435
|
+
/** @type {Array<[number, number]>} */
|
|
436
|
+
const points = from === to ? [[centerX(fromBox), y], [centerX(fromBox) + 64, y], [centerX(fromBox) + 64, y + 32], [centerX(fromBox), y + 32]] : [[centerX(fromBox), y], [centerX(toBox), y]];
|
|
437
|
+
occupiedRoutes.push(points);
|
|
438
|
+
const variant = normalizeVariant(message.variant, `/messages/${index}/variant`);
|
|
439
|
+
connectors.push({ id: `${from}-${to}-${index}`, from, to, points, kind: "sequence", self: from === to });
|
|
440
|
+
return renderConnector(points, variant, typeof message.label === "string" ? message.label : "");
|
|
441
|
+
}).join("\n");
|
|
442
|
+
const boxSvg = [...boxes.values()].map(renderBox).join("\n");
|
|
443
|
+
height=Math.max(height,messageY+80);
|
|
444
|
+
const lifelines = [...boxes.values()].map((box) => `<line x1="${centerX(box)}" y1="${box.y + box.height}" x2="${centerX(box)}" y2="${height - 56}" class="lifeline"/>`).join("\n");
|
|
445
|
+
const svg = wrapSvg(width, height, `${lifelines}\n${messageSvg}\n${boxSvg}`);
|
|
446
|
+
|
|
447
|
+
return { svg, manifest: manifestFrom(width, height, [...boxes.values()], connectors) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* @param {DataflowDiagram} diagram
|
|
452
|
+
*/
|
|
453
|
+
function renderDataflow(diagram) {
|
|
454
|
+
const stages = requireObjectArray(diagram.stages, "/stages");
|
|
455
|
+
const nodes = requireObjectArray(diagram.nodes, "/nodes");
|
|
456
|
+
const flows = requireObjectArray(diagram.flows, "/flows");
|
|
457
|
+
const width = Math.max(860, 150 + stages.length * 180);
|
|
458
|
+
const height = Math.max(460, 180 + maxRowsByStage(nodes) * 110);
|
|
459
|
+
const stageWidth = (width - 84) / Math.max(1, stages.length);
|
|
460
|
+
/** @type {Map<string, Box>} */
|
|
461
|
+
const boxes = new Map();
|
|
462
|
+
|
|
463
|
+
for (const [index, stage] of stages.entries()) {
|
|
464
|
+
assertObject(stage, `/stages/${index}`);
|
|
465
|
+
requireString(stage, "label", `/stages/${index}/label`);
|
|
466
|
+
}
|
|
467
|
+
for (const [index, node] of nodes.entries()) {
|
|
468
|
+
assertObject(node, `/nodes/${index}`);
|
|
469
|
+
const id = requireId(node, "id", `/nodes/${index}/id`);
|
|
470
|
+
ensureUnique(boxes, id, `/nodes/${index}/id`);
|
|
471
|
+
const stage = requireNumber(node, "stage", `/nodes/${index}/stage`);
|
|
472
|
+
if (stage < 0 || stage >= stages.length) throw usageError(`/nodes/${index}/stage`, `Stage index ${stage} is outside /stages.`);
|
|
473
|
+
const row = typeof node.row === "number" ? node.row : countStageBefore(nodes, index, stage);
|
|
474
|
+
const type = normalizeType(node.type, `/nodes/${index}/type`);
|
|
475
|
+
boxes.set(id, {
|
|
476
|
+
id,
|
|
477
|
+
x: 54 + stage * stageWidth + Math.max(8, stageWidth / 2 - 64),
|
|
478
|
+
y: 118 + row * 96,
|
|
479
|
+
width: 128,
|
|
480
|
+
height: 58,
|
|
481
|
+
label: requireString(node, "label", `/nodes/${index}/label`),
|
|
482
|
+
sublabel: typeof node.sublabel === "string" ? node.sublabel : "",
|
|
483
|
+
type,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
prepareBoxes(boxes, nodes);
|
|
488
|
+
|
|
489
|
+
/** @type {Connector[]} */
|
|
490
|
+
const connectors = [];
|
|
491
|
+
const stageSvg = stages.map((stage, index) => {
|
|
492
|
+
const x = 42 + index * stageWidth;
|
|
493
|
+
return `<g class="stage"><rect x="${x}" y="64" width="${stageWidth - 10}" height="${height - 118}" rx="12"/><text x="${x + 18}" y="92" class="label muted">${escapeHtml(String(stage.label))}</text></g>`;
|
|
494
|
+
}).join("\n");
|
|
495
|
+
const flowSvg = flows.map((flow, index) => {
|
|
496
|
+
assertObject(flow, `/flows/${index}`);
|
|
497
|
+
const from = requireId(flow, "from", `/flows/${index}/from`);
|
|
498
|
+
const to = requireId(flow, "to", `/flows/${index}/to`);
|
|
499
|
+
const fromBox = boxes.get(from);
|
|
500
|
+
const toBox = boxes.get(to);
|
|
501
|
+
if (!fromBox) throw usageError(`/flows/${index}/from`, `Unknown endpoint "${from}".`);
|
|
502
|
+
if (!toBox) throw usageError(`/flows/${index}/to`, `Unknown endpoint "${to}".`);
|
|
503
|
+
const variant = normalizeVariant(flow.variant, `/flows/${index}/variant`);
|
|
504
|
+
const points = routeBoxes(fromBox, toBox);
|
|
505
|
+
connectors.push({ id: `${from}-${to}-${index}`, from, to, points });
|
|
506
|
+
return renderConnector(points, variant, typeof flow.label === "string" ? flow.label : "");
|
|
507
|
+
}).join("\n");
|
|
508
|
+
const boxSvg = [...boxes.values()].map(renderBox).join("\n");
|
|
509
|
+
const svg = wrapSvg(width, height, `${stageSvg}\n${flowSvg}\n${boxSvg}`);
|
|
510
|
+
|
|
511
|
+
return { svg, manifest: manifestFrom(width, height, [...boxes.values()], connectors) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* @param {Array<Record<string, unknown>>} nodes
|
|
516
|
+
*/
|
|
517
|
+
function maxRowsByStage(nodes) {
|
|
518
|
+
/** @type {Map<number, number>} */
|
|
519
|
+
const rows = new Map();
|
|
520
|
+
for (const node of nodes) {
|
|
521
|
+
if (typeof node.stage === "number") {
|
|
522
|
+
rows.set(node.stage, (rows.get(node.stage) ?? 0) + 1);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return Math.max(1, ...rows.values());
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* @param {Array<Record<string, unknown>>} nodes
|
|
530
|
+
* @param {number} endIndex
|
|
531
|
+
* @param {number} stage
|
|
532
|
+
*/
|
|
533
|
+
function countStageBefore(nodes, endIndex, stage) {
|
|
534
|
+
let count = 0;
|
|
535
|
+
for (let index = 0; index < endIndex; index += 1) {
|
|
536
|
+
const node = nodes[index];
|
|
537
|
+
if (node.stage === stage) count += 1;
|
|
538
|
+
}
|
|
539
|
+
return count;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
/** @param {Map<string, Box>} boxes @param {Array<Record<string, unknown>>} sources */
|
|
544
|
+
function prepareBoxes(boxes, sources) {
|
|
545
|
+
for (const [id, box] of boxes) {
|
|
546
|
+
const source = sources.find((item) => item.id === id) ?? {};
|
|
547
|
+
const fitted = fitNodeText(box, { width: typeof source.width === "number", height: typeof source.height === "number" });
|
|
548
|
+
boxes.set(id, fitted.box);
|
|
549
|
+
qualityIssues.push(...fitted.issues);
|
|
550
|
+
}
|
|
551
|
+
routeObstacles = [...boxes.values()];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** @param {string} id @param {string} owner @param {string} kind @param {string} text @param {import("./diagram-layout.mjs").TextLayout} layout @param {number} cx @param {number} top @param {string} cls */
|
|
555
|
+
function renderText(id, owner, kind, text, layout, cx, top, cls) {
|
|
556
|
+
textBounds.push({ id, owner, kind, text, lines: layout.lines, x: cx - layout.width / 2, y: top, width: layout.width, height: layout.height });
|
|
557
|
+
return `<text data-label-id="${escapeHtml(id)}" x="${cx}" y="${top + layout.fontSize}" class="${cls}" font-size="${layout.fontSize}" text-anchor="middle">${layout.lines.map((line, i) => `<tspan x="${cx}" dy="${i ? layout.lineHeight : 0}">${escapeHtml(line)}</tspan>`).join("")}</text>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @param {Box} box
|
|
562
|
+
*/
|
|
563
|
+
function renderBox(box) {
|
|
564
|
+
const shape = box.shape ?? "box";
|
|
565
|
+
const isSemantic = isSemanticNodeType(box.type);
|
|
566
|
+
const available = shape === "diamond" ? box.width * 0.5 - 12 : (box.type === "queue" ? box.width - 64 : box.width - 32);
|
|
567
|
+
const main = layoutText(box.label, available);
|
|
568
|
+
const sub = layoutText(box.sublabel, available, 12, 16);
|
|
569
|
+
const total = main.height + (box.sublabel ? sub.height + 6 : 0);
|
|
570
|
+
const textTop = shape === "diamond" ? centerY(box) - total / 2 : box.y + (["database", "vectorstore"].includes(box.type) ? 30 : box.type === "queue" ? 20 : isSemantic ? 46 : box.actor ? 40 : 20);
|
|
571
|
+
const labelSvg = renderText(box.id + "-label", box.id, "node", box.label, main, centerX(box), textTop, "label");
|
|
572
|
+
const sublabel = box.sublabel ? renderText(box.id + "-sub", box.id, "node", box.sublabel, sub, centerX(box), textTop + main.height + 6, "sublabel") : "";
|
|
573
|
+
const actorLayout=layoutText(box.actor??"",box.width-32,12,16);
|
|
574
|
+
const actor = box.actor ? renderText(box.id+"-actor",box.id,"group",box.actor,actorLayout,centerX(box),box.y-actorLayout.height-12,"actor") : "";
|
|
575
|
+
const stepLayout=layoutText(box.step??"",Math.max(14,box.width-16),9,12);
|
|
576
|
+
const step = box.step ? `<g class="step-badge"><circle cx="${box.x + 13}" cy="${box.y + 13}" r="${Math.max(10,stepLayout.width/2+4)}"/>${renderText(box.id+"-step",box.id,"group",box.step,stepLayout,box.x+13,box.y+7,"step-label")}</g>` : "";
|
|
577
|
+
const shapeSvg = renderSemanticShape(box) ?? (shape === "diamond"
|
|
578
|
+
? `<polygon points="${centerX(box)},${box.y} ${box.x + box.width},${centerY(box)} ${centerX(box)},${box.y + box.height} ${box.x},${centerY(box)}"/>`
|
|
579
|
+
: `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="${shape === "terminal" ? Math.round(box.height / 2) : 10}"/>`);
|
|
580
|
+
return `<g data-node-id="${escapeHtml(box.id)}" class="node node-${box.type}">${actor}\n${shapeSvg}\n${step}\n${labelSvg}\n${sublabel}</g>`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* @param {Box} box
|
|
585
|
+
* @returns {string | null}
|
|
586
|
+
*/
|
|
587
|
+
function renderSemanticShape(box) {
|
|
588
|
+
const cx = centerX(box);
|
|
589
|
+
const cy = centerY(box);
|
|
590
|
+
if (box.type === "agent") {
|
|
591
|
+
const inset = Math.min(28, box.width * 0.18);
|
|
592
|
+
return `<polygon points="${box.x + inset},${box.y} ${box.x + box.width - inset},${box.y} ${box.x + box.width},${cy} ${box.x + box.width - inset},${box.y + box.height} ${box.x + inset},${box.y + box.height} ${box.x},${cy}"/><rect x="${box.x + 5}" y="${box.y + 5}" width="${box.width - 10}" height="${box.height - 10}" rx="10" fill="none" opacity="0.45"/>`;
|
|
593
|
+
}
|
|
594
|
+
if (box.type === "model") {
|
|
595
|
+
return `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="14"/><rect x="${box.x + 5}" y="${box.y + 5}" width="${box.width - 10}" height="${box.height - 10}" rx="10" fill="none" opacity="0.5"/><path d="M ${cx - 12} ${box.y + 20} L ${cx - 4} ${box.y + 20} L ${cx} ${box.y + 10} L ${cx + 4} ${box.y + 20} L ${cx + 12} ${box.y + 20} L ${cx + 6} ${box.y + 27} L ${cx + 9} ${box.y + 38} L ${cx} ${box.y + 31} L ${cx - 9} ${box.y + 38} L ${cx - 6} ${box.y + 27} Z" class="node-glyph"/>`;
|
|
596
|
+
}
|
|
597
|
+
if (box.type === "memory") {
|
|
598
|
+
return `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="12" stroke-dasharray="6 4"/>`;
|
|
599
|
+
}
|
|
600
|
+
if (box.type === "database" || box.type === "vectorstore") {
|
|
601
|
+
const ry = Math.min(14, box.height / 5);
|
|
602
|
+
const rings = box.type === "vectorstore" ? `<ellipse cx="${cx}" cy="${box.y + box.height * 0.45}" rx="${box.width / 2}" ry="${ry}" fill="none" opacity="0.45"/><ellipse cx="${cx}" cy="${box.y + box.height * 0.66}" rx="${box.width / 2}" ry="${ry}" fill="none" opacity="0.32"/>` : "";
|
|
603
|
+
return `<ellipse cx="${cx}" cy="${box.y + ry}" rx="${box.width / 2}" ry="${ry}"/><rect x="${box.x}" y="${box.y + ry}" width="${box.width}" height="${box.height - ry * 2}" stroke="none"/><line x1="${box.x}" y1="${box.y + ry}" x2="${box.x}" y2="${box.y + box.height - ry}"/><line x1="${box.x + box.width}" y1="${box.y + ry}" x2="${box.x + box.width}" y2="${box.y + box.height - ry}"/>${rings}<ellipse cx="${cx}" cy="${box.y + box.height - ry}" rx="${box.width / 2}" ry="${ry}"/>`;
|
|
604
|
+
}
|
|
605
|
+
if (box.type === "graphdb") {
|
|
606
|
+
return `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="14"/><circle cx="${cx - 18}" cy="${cy - 2}" r="12" class="node-glyph-outline"/><circle cx="${cx + 8}" cy="${cy - 14}" r="10" class="node-glyph-outline"/><circle cx="${cx + 22}" cy="${cy + 10}" r="11" class="node-glyph-outline"/><line x1="${cx - 7}" y1="${cy - 7}" x2="${cx}" y2="${cy - 12}" class="node-glyph-line"/><line x1="${cx + 16}" y1="${cy - 5}" x2="${cx + 20}" y2="${cy}" class="node-glyph-line"/>`;
|
|
607
|
+
}
|
|
608
|
+
if (box.type === "tool") {
|
|
609
|
+
return `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="10"/><circle cx="${cx}" cy="${box.y + 24}" r="9" class="node-glyph-outline"/><line x1="${cx}" y1="${box.y + 11}" x2="${cx}" y2="${box.y + 16}" class="node-glyph-line"/><line x1="${cx}" y1="${box.y + 32}" x2="${cx}" y2="${box.y + 37}" class="node-glyph-line"/><line x1="${cx - 13}" y1="${box.y + 24}" x2="${cx - 8}" y2="${box.y + 24}" class="node-glyph-line"/><line x1="${cx + 8}" y1="${box.y + 24}" x2="${cx + 13}" y2="${box.y + 24}" class="node-glyph-line"/>`;
|
|
610
|
+
}
|
|
611
|
+
if (box.type === "document") {
|
|
612
|
+
const fold = Math.min(16, box.width * 0.16);
|
|
613
|
+
return `<path d="M ${box.x} ${box.y} L ${box.x + box.width - fold} ${box.y} L ${box.x + box.width} ${box.y + fold} L ${box.x + box.width} ${box.y + box.height} L ${box.x} ${box.y + box.height} Z"/><path d="M ${box.x + box.width - fold} ${box.y} L ${box.x + box.width - fold} ${box.y + fold} L ${box.x + box.width} ${box.y + fold}" fill="none"/>`;
|
|
614
|
+
}
|
|
615
|
+
if (box.type === "queue") {
|
|
616
|
+
const ry = box.height / 2;
|
|
617
|
+
const end = Math.min(18, box.width / 6);
|
|
618
|
+
return `<ellipse cx="${box.x + end}" cy="${cy}" rx="${end}" ry="${ry}"/><rect x="${box.x + end}" y="${cy - ry}" width="${box.width - end * 2}" height="${ry * 2}" stroke="none"/><line x1="${box.x + end}" y1="${cy - ry}" x2="${box.x + box.width - end}" y2="${cy - ry}"/><line x1="${box.x + end}" y1="${cy + ry}" x2="${box.x + box.width - end}" y2="${cy + ry}"/><ellipse cx="${box.x + box.width - end}" cy="${cy}" rx="${end}" ry="${ry}"/>`;
|
|
619
|
+
}
|
|
620
|
+
if (box.type === "browser") {
|
|
621
|
+
return `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="10"/><rect x="${box.x}" y="${box.y}" width="${box.width}" height="20" rx="10" class="node-chrome"/><circle cx="${box.x + 14}" cy="${box.y + 10}" r="3" class="node-dot"/><circle cx="${box.x + 26}" cy="${box.y + 10}" r="3" class="node-dot"/><circle cx="${box.x + 38}" cy="${box.y + 10}" r="3" class="node-dot"/>`;
|
|
622
|
+
}
|
|
623
|
+
if (box.type === "user") {
|
|
624
|
+
return `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="12"/><circle cx="${cx}" cy="${box.y + 22}" r="9" class="node-glyph-outline"/><path d="M ${cx - 16} ${box.y + 43} Q ${cx} ${box.y + 28} ${cx + 16} ${box.y + 43}" class="node-glyph-line"/>`;
|
|
625
|
+
}
|
|
626
|
+
if (box.type === "gateway") {
|
|
627
|
+
const inset = Math.min(22, box.width * 0.18);
|
|
628
|
+
return `<polygon points="${box.x + inset},${box.y} ${box.x + box.width - inset},${box.y} ${box.x + box.width},${cy} ${box.x + box.width - inset},${box.y + box.height} ${box.x + inset},${box.y + box.height} ${box.x},${cy}"/>`;
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* @param {Box} box
|
|
635
|
+
*/
|
|
636
|
+
function renderGroup(box) {
|
|
637
|
+
return `<g class="group group-${box.type}"><rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="14"/>${renderText(box.id+"-group",box.id,"group",box.label,layoutText(box.label,box.width-32,12,16),box.x+box.width/2,box.y+10,"sublabel")}</g>`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* @param {Array<[number, number]>} points
|
|
642
|
+
* @param {string} variant
|
|
643
|
+
* @param {string} label
|
|
644
|
+
*/
|
|
645
|
+
function renderConnector(points, variant, label, flow = "") {
|
|
646
|
+
const pathData = points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x} ${y}`).join(" ");
|
|
647
|
+
const textLayout = layoutText(label, 190, 12, 16);
|
|
648
|
+
const placed = placeEdgeLabel(points, textLayout, [...routeObstacles, ...textBounds]);
|
|
649
|
+
const id = `edge-label-${textBounds.length}`;
|
|
650
|
+
if (label) qualityIssues.push(...placed.issues.map((issue) => ({ ...issue, id })));
|
|
651
|
+
if(label) deferredLabels.push({id,label,points});
|
|
652
|
+
const labelSvg = label ? renderText(id, id, "edge", label, textLayout, placed.rect.x + placed.rect.width / 2, placed.rect.y, "edge-label") : "";
|
|
653
|
+
const marker = flow ? `flow-${flow}` : (variant === "return" ? "default" : variant);
|
|
654
|
+
return `<g class="edge edge-${variant}${flow ? ` flow-${flow}` : ""}"><path d="${pathData}" marker-end="url(#arrow-${marker})"/>\n${labelSvg}</g>`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* @param {number} width
|
|
659
|
+
* @param {number} height
|
|
660
|
+
* @param {string} body
|
|
661
|
+
*/
|
|
662
|
+
function wrapSvg(width, height, body) {
|
|
663
|
+
for(let i=textBounds.length-1;i>=0;i--) if(textBounds[i].kind === "edge") textBounds.splice(i,1);
|
|
664
|
+
for(const {id,label,points} of deferredLabels) {
|
|
665
|
+
const layout=layoutText(label,190,12,16);
|
|
666
|
+
const placed=placeEdgeLabel(points,layout,[...routeObstacles,...textBounds],occupiedRoutes);
|
|
667
|
+
qualityIssues.push(...placed.issues);
|
|
668
|
+
const replacement=renderText(id,id,"edge",label,layout,placed.rect.x+placed.rect.width/2,placed.rect.y,"edge-label");
|
|
669
|
+
body=body.replace(new RegExp('<text data-label-id="'+id+'"[^>]*>[\\s\\S]*?</text>'),replacement);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
width = Math.ceil(Math.max(width, ...routeObstacles.map((b) => b.x + b.width + 40), ...textBounds.map((b) => b.x + b.width + 40), ...occupiedRoutes.flat().map((p) => p[0] + 40)));
|
|
673
|
+
height = Math.ceil(Math.max(height, ...routeObstacles.map((b) => b.y + b.height + 40), ...textBounds.map((b) => b.y + b.height + 40), ...occupiedRoutes.flat().map((p) => p[1] + 40)));
|
|
674
|
+
finalCanvas = { width, height };
|
|
675
|
+
return `<svg class="tech-diagram" data-theme="light" data-visual-style="default" data-export-root="1" role="img" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
676
|
+
<defs>
|
|
677
|
+
<marker id="arrow-default" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
678
|
+
<marker id="arrow-emphasis" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
679
|
+
<marker id="arrow-security" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
680
|
+
<marker id="arrow-dashed" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
681
|
+
<marker id="arrow-flow-control" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
682
|
+
<marker id="arrow-flow-data" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
683
|
+
<marker id="arrow-flow-read" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
684
|
+
<marker id="arrow-flow-write" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
685
|
+
<marker id="arrow-flow-async" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
686
|
+
<marker id="arrow-flow-feedback" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z"/></marker>
|
|
687
|
+
</defs>
|
|
688
|
+
<rect x="16" y="18" width="${width - 32}" height="${height - 36}" rx="18" class="surface"/>
|
|
689
|
+
${body}
|
|
690
|
+
</svg>`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* @param {string} title
|
|
695
|
+
* @param {string} subtitle
|
|
696
|
+
* @param {string} svg
|
|
697
|
+
* @param {string} type
|
|
698
|
+
* @param {SummaryCard[]} [summary]
|
|
699
|
+
*/
|
|
700
|
+
function renderHtml(title, subtitle, svg, type, summary = [], style = "default") {
|
|
701
|
+
const summaryHtml = renderSummaryCards(summary);
|
|
702
|
+
return `<!doctype html>
|
|
703
|
+
<html lang="en" data-theme="auto" data-visual-style="${escapeHtml(style)}">
|
|
704
|
+
<head>
|
|
705
|
+
<meta charset="utf-8">
|
|
706
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
707
|
+
<title>${escapeHtml(title)}</title>
|
|
708
|
+
<style>
|
|
709
|
+
:root {
|
|
710
|
+
color-scheme: dark light;
|
|
711
|
+
--bg: #0f172a;
|
|
712
|
+
--panel: #111827;
|
|
713
|
+
--surface: #172033;
|
|
714
|
+
--text: #f8fafc;
|
|
715
|
+
--muted: #94a3b8;
|
|
716
|
+
--line: #64748b;
|
|
717
|
+
--frontend: #38bdf8;
|
|
718
|
+
--backend: #34d399;
|
|
719
|
+
--database: #a78bfa;
|
|
720
|
+
--cloud: #fbbf24;
|
|
721
|
+
--security: #fb7185;
|
|
722
|
+
--messagebus: #fb923c;
|
|
723
|
+
--external: #cbd5e1;
|
|
724
|
+
--agent: #22d3ee;
|
|
725
|
+
--model: #c084fc;
|
|
726
|
+
--memory: #34d399;
|
|
727
|
+
--vectorstore: #60a5fa;
|
|
728
|
+
--graphdb: #f472b6;
|
|
729
|
+
--tool: #f59e0b;
|
|
730
|
+
--document: #cbd5e1;
|
|
731
|
+
--queue: #fb923c;
|
|
732
|
+
--browser: #38bdf8;
|
|
733
|
+
--user: #fda4af;
|
|
734
|
+
--gateway: #fbbf24;
|
|
735
|
+
--flow-control: #f59e0b;
|
|
736
|
+
--flow-data: #38bdf8;
|
|
737
|
+
--flow-read: #34d399;
|
|
738
|
+
--flow-write: #22c55e;
|
|
739
|
+
--flow-async: #94a3b8;
|
|
740
|
+
--flow-feedback: #c084fc;
|
|
741
|
+
}
|
|
742
|
+
[data-theme="light"] {
|
|
743
|
+
--bg: #f8fafc;
|
|
744
|
+
--panel: #ffffff;
|
|
745
|
+
--surface: #eef2f7;
|
|
746
|
+
--text: #0f172a;
|
|
747
|
+
--muted: #64748b;
|
|
748
|
+
--line: #475569;
|
|
749
|
+
}
|
|
750
|
+
[data-visual-style="terminal"] {
|
|
751
|
+
--bg: #0b1020;
|
|
752
|
+
--panel: #0f172a;
|
|
753
|
+
--surface: #111827;
|
|
754
|
+
--text: #e2e8f0;
|
|
755
|
+
--muted: #94a3b8;
|
|
756
|
+
--frontend: #38bdf8;
|
|
757
|
+
--backend: #22c55e;
|
|
758
|
+
--security: #f472b6;
|
|
759
|
+
}
|
|
760
|
+
[data-visual-style="blueprint"] {
|
|
761
|
+
--bg: #082f49;
|
|
762
|
+
--panel: #0b3b5e;
|
|
763
|
+
--surface: #0c4a6e;
|
|
764
|
+
--text: #e0f2fe;
|
|
765
|
+
--muted: #7dd3fc;
|
|
766
|
+
--line: #67e8f9;
|
|
767
|
+
--frontend: #38bdf8;
|
|
768
|
+
--backend: #67e8f9;
|
|
769
|
+
--database: #fde047;
|
|
770
|
+
--cloud: #bae6fd;
|
|
771
|
+
}
|
|
772
|
+
[data-visual-style="minimal"] {
|
|
773
|
+
--bg: #f8fafc;
|
|
774
|
+
--panel: #ffffff;
|
|
775
|
+
--surface: #f1f5f9;
|
|
776
|
+
--text: #111827;
|
|
777
|
+
--muted: #64748b;
|
|
778
|
+
--line: #94a3b8;
|
|
779
|
+
}
|
|
780
|
+
[data-visual-style="glass"] {
|
|
781
|
+
--bg: #111827;
|
|
782
|
+
--panel: color-mix(in srgb, #1f2937, transparent 18%);
|
|
783
|
+
--surface: color-mix(in srgb, #334155, transparent 18%);
|
|
784
|
+
--text: #f8fafc;
|
|
785
|
+
--muted: #cbd5e1;
|
|
786
|
+
}
|
|
787
|
+
[data-visual-style="warm"] {
|
|
788
|
+
--bg: #f8f6f3;
|
|
789
|
+
--panel: #fffcf7;
|
|
790
|
+
--surface: #f1ede6;
|
|
791
|
+
--text: #141413;
|
|
792
|
+
--muted: #7c756c;
|
|
793
|
+
--line: #8b7355;
|
|
794
|
+
--frontend: #d97757;
|
|
795
|
+
--backend: #7b8b5c;
|
|
796
|
+
--database: #8c6f5a;
|
|
797
|
+
--cloud: #b45309;
|
|
798
|
+
}
|
|
799
|
+
[data-visual-style="openai"] {
|
|
800
|
+
--bg: #ffffff;
|
|
801
|
+
--panel: #ffffff;
|
|
802
|
+
--surface: #f8fafc;
|
|
803
|
+
--text: #0f172a;
|
|
804
|
+
--muted: #64748b;
|
|
805
|
+
--line: #10a37f;
|
|
806
|
+
--frontend: #10a37f;
|
|
807
|
+
--backend: #0f766e;
|
|
808
|
+
--database: #0891b2;
|
|
809
|
+
}
|
|
810
|
+
[data-visual-style="editorial-dark"] {
|
|
811
|
+
--bg: #0a0a0a;
|
|
812
|
+
--panel: #111111;
|
|
813
|
+
--surface: #181818;
|
|
814
|
+
--text: #f5f0e8;
|
|
815
|
+
--muted: #b8a88f;
|
|
816
|
+
--line: #d4a574;
|
|
817
|
+
--frontend: #38bdf8;
|
|
818
|
+
--backend: #34d399;
|
|
819
|
+
--database: #a78bfa;
|
|
820
|
+
--cloud: #d4a574;
|
|
821
|
+
--security: #fb7185;
|
|
822
|
+
}
|
|
823
|
+
* { box-sizing: border-box; }
|
|
824
|
+
body {
|
|
825
|
+
margin: 0;
|
|
826
|
+
min-height: 100vh;
|
|
827
|
+
background: var(--bg);
|
|
828
|
+
color: var(--text);
|
|
829
|
+
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
830
|
+
}
|
|
831
|
+
main { width: min(1180px, calc(100vw - 32px)); margin: 0 auto; padding: 28px 0; }
|
|
832
|
+
header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
|
|
833
|
+
h1 { font-size: 24px; margin: 0; letter-spacing: 0; }
|
|
834
|
+
p { margin: 6px 0 0; color: var(--muted); }
|
|
835
|
+
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
836
|
+
button {
|
|
837
|
+
border: 1px solid color-mix(in srgb, var(--muted), transparent 55%);
|
|
838
|
+
background: var(--panel);
|
|
839
|
+
color: var(--text);
|
|
840
|
+
border-radius: 8px;
|
|
841
|
+
padding: 8px 11px;
|
|
842
|
+
cursor: pointer;
|
|
843
|
+
}
|
|
844
|
+
.diagram-frame { background: var(--panel); border: 1px solid color-mix(in srgb, var(--muted), transparent 70%); border-radius: 12px; overflow: auto; }
|
|
845
|
+
.tech-diagram { display: block; min-width: 720px; width: 100%; height: auto; }
|
|
846
|
+
.group rect { fill: color-mix(in srgb, currentColor, transparent 94%); stroke: currentColor; stroke-width: 1.2; stroke-dasharray: 8 5; }
|
|
847
|
+
.group-frontend { color: var(--frontend); }
|
|
848
|
+
.group-backend, .group-active, .group-start { color: var(--backend); }
|
|
849
|
+
.group-database { color: var(--database); }
|
|
850
|
+
.group-cloud, .group-decision { color: var(--cloud); }
|
|
851
|
+
.group-security, .group-failure, .group-waiting { color: var(--security); }
|
|
852
|
+
.group-messagebus { color: var(--messagebus); }
|
|
853
|
+
.group-external, .group-neutral, .group-success { color: var(--external); }
|
|
854
|
+
.surface { fill: var(--surface); stroke: color-mix(in srgb, var(--muted), transparent 70%); }
|
|
855
|
+
.lane rect, .stage rect { fill: color-mix(in srgb, var(--surface), transparent 25%); stroke: color-mix(in srgb, var(--muted), transparent 72%); }
|
|
856
|
+
.node rect, .node polygon, .node path, .node ellipse, .node line, .node circle { fill: color-mix(in srgb, currentColor, transparent 86%); stroke: currentColor; stroke-width: 1.6; }
|
|
857
|
+
.node-frontend { color: var(--frontend); }
|
|
858
|
+
.node-backend, .node-active, .node-start, .node-success { color: var(--backend); }
|
|
859
|
+
.node-database { color: var(--database); }
|
|
860
|
+
.node-cloud, .node-decision { color: var(--cloud); }
|
|
861
|
+
.node-security, .node-failure, .node-waiting { color: var(--security); }
|
|
862
|
+
.node-messagebus { color: var(--messagebus); }
|
|
863
|
+
.node-external, .node-neutral { color: var(--external); }
|
|
864
|
+
.node-agent { color: var(--agent); }
|
|
865
|
+
.node-model { color: var(--model); }
|
|
866
|
+
.node-memory { color: var(--memory); }
|
|
867
|
+
.node-vectorstore { color: var(--vectorstore); }
|
|
868
|
+
.node-graphdb { color: var(--graphdb); }
|
|
869
|
+
.node-tool { color: var(--tool); }
|
|
870
|
+
.node-document { color: var(--document); }
|
|
871
|
+
.node-queue { color: var(--queue); }
|
|
872
|
+
.node-browser { color: var(--browser); }
|
|
873
|
+
.node-user { color: var(--user); }
|
|
874
|
+
.node-gateway { color: var(--gateway); }
|
|
875
|
+
.node-glyph { fill: currentColor; stroke: none; }
|
|
876
|
+
.node-glyph-outline, .node-glyph-line { fill: none; stroke: currentColor; }
|
|
877
|
+
.node-chrome { fill: currentColor; opacity: 0.25; stroke: none; }
|
|
878
|
+
.node-dot { fill: currentColor; stroke: none; opacity: 0.65; }
|
|
879
|
+
.label { fill: var(--text); font-weight: 650; font-size: 14px; }
|
|
880
|
+
.sublabel, .muted, .actor { fill: var(--muted); font-size: 12px; }
|
|
881
|
+
.actor { font-weight: 650; }
|
|
882
|
+
.step-badge circle { fill: var(--panel); stroke: currentColor; stroke-width: 1.3; }
|
|
883
|
+
.step-badge text { fill: var(--text); font-size: 9px; font-weight: 750; }
|
|
884
|
+
.edge path { fill: none; stroke: var(--line); stroke-width: 1.6; }
|
|
885
|
+
.edge-emphasis path { stroke: var(--frontend); stroke-width: 2; }
|
|
886
|
+
.edge-security path { stroke: var(--security); stroke-dasharray: 5 4; }
|
|
887
|
+
.edge-dashed path, .edge-return path { stroke-dasharray: 5 4; }
|
|
888
|
+
.flow-control path { stroke: var(--flow-control); }
|
|
889
|
+
.flow-data path { stroke: var(--flow-data); stroke-width: 2; }
|
|
890
|
+
.flow-read path { stroke: var(--flow-read); }
|
|
891
|
+
.flow-write path { stroke: var(--flow-write); stroke-dasharray: 5 4; }
|
|
892
|
+
.flow-async path { stroke: var(--flow-async); stroke-dasharray: 4 4; }
|
|
893
|
+
.flow-feedback path { stroke: var(--flow-feedback); stroke-width: 2; }
|
|
894
|
+
.edge-label { fill: var(--muted); font-size: 12px; paint-order: stroke; stroke: var(--surface); stroke-width: 5px; stroke-linejoin: round; }
|
|
895
|
+
.lifeline { stroke: color-mix(in srgb, var(--muted), transparent 48%); stroke-dasharray: 4 5; }
|
|
896
|
+
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-top: 12px; }
|
|
897
|
+
.summary-card { background: var(--panel); border: 1px solid color-mix(in srgb, var(--muted), transparent 74%); border-radius: 8px; padding: 12px; }
|
|
898
|
+
.summary-card h2 { margin: 0 0 7px; font-size: 13px; letter-spacing: 0; }
|
|
899
|
+
.summary-card ul { margin: 0; padding-left: 18px; color: var(--muted); font-size: 12px; }
|
|
900
|
+
.summary-card li + li { margin-top: 4px; }
|
|
901
|
+
marker path { fill: var(--line); }
|
|
902
|
+
#arrow-emphasis path { fill: var(--frontend); }
|
|
903
|
+
#arrow-security path { fill: var(--security); }
|
|
904
|
+
#arrow-dashed path { fill: var(--line); }
|
|
905
|
+
#arrow-flow-control path { fill: var(--flow-control); }
|
|
906
|
+
#arrow-flow-data path { fill: var(--flow-data); }
|
|
907
|
+
#arrow-flow-read path { fill: var(--flow-read); }
|
|
908
|
+
#arrow-flow-write path { fill: var(--flow-write); }
|
|
909
|
+
#arrow-flow-async path { fill: var(--flow-async); }
|
|
910
|
+
#arrow-flow-feedback path { fill: var(--flow-feedback); }
|
|
911
|
+
footer { margin-top: 12px; color: var(--muted); font-size: 12px; }
|
|
912
|
+
@media print {
|
|
913
|
+
body { background: #fff; }
|
|
914
|
+
main { width: 100%; padding: 0; }
|
|
915
|
+
.toolbar, footer { display: none; }
|
|
916
|
+
.diagram-frame { border: 0; }
|
|
917
|
+
}
|
|
918
|
+
</style>
|
|
919
|
+
</head>
|
|
920
|
+
<body>
|
|
921
|
+
<main>
|
|
922
|
+
<header>
|
|
923
|
+
<div>
|
|
924
|
+
<h1>${escapeHtml(title)}</h1>
|
|
925
|
+
<p>${escapeHtml(subtitle || `${type} technical diagram`)}</p>
|
|
926
|
+
</div>
|
|
927
|
+
<div class="toolbar" aria-label="Diagram actions">
|
|
928
|
+
<button type="button" data-action="theme">Toggle theme</button>
|
|
929
|
+
<button type="button" data-action="svg">Download SVG</button>
|
|
930
|
+
<button type="button" data-action="print">Print / Save PDF</button>
|
|
931
|
+
</div>
|
|
932
|
+
</header>
|
|
933
|
+
<section class="diagram-frame" aria-label="${escapeHtml(title)}">
|
|
934
|
+
${svg}
|
|
935
|
+
</section>
|
|
936
|
+
${summaryHtml}
|
|
937
|
+
<footer>Generated as a self-contained Frontend Craft HTML technical diagram. Export raster images from the browser after visual QA.</footer>
|
|
938
|
+
</main>
|
|
939
|
+
<script>
|
|
940
|
+
const root = document.documentElement;
|
|
941
|
+
const stored = localStorage.getItem("fec-tech-diagram-theme");
|
|
942
|
+
const diagramSvg = document.querySelector("[data-export-root]");
|
|
943
|
+
root.dataset.theme = stored === "dark" ? "dark" : "light";
|
|
944
|
+
diagramSvg.dataset.theme = root.dataset.theme;
|
|
945
|
+
diagramSvg.dataset.visualStyle = root.dataset.visualStyle;
|
|
946
|
+
document.querySelector('[data-action="theme"]').addEventListener("click", () => {
|
|
947
|
+
const next = root.dataset.theme === "light" ? "dark" : "light";
|
|
948
|
+
root.dataset.theme = next;
|
|
949
|
+
diagramSvg.dataset.theme = next;
|
|
950
|
+
localStorage.setItem("fec-tech-diagram-theme", next);
|
|
951
|
+
});
|
|
952
|
+
document.querySelector('[data-action="print"]').addEventListener("click", () => window.print());
|
|
953
|
+
document.querySelector('[data-action="svg"]').addEventListener("click", () => {
|
|
954
|
+
const svg = document.querySelector("[data-export-root]");
|
|
955
|
+
const clone = svg.cloneNode(true);
|
|
956
|
+
const originals = [svg, ...svg.querySelectorAll("*")];
|
|
957
|
+
const copies = [clone, ...clone.querySelectorAll("*")];
|
|
958
|
+
const properties = ["fill","stroke","stroke-width","stroke-dasharray","font-family","font-size","font-weight","color","opacity","paint-order","text-anchor"];
|
|
959
|
+
originals.forEach((element,index) => { const computed=getComputedStyle(element); properties.forEach(property=>copies[index].style?.setProperty(property,computed.getPropertyValue(property))); });
|
|
960
|
+
const source = new XMLSerializer().serializeToString(clone);
|
|
961
|
+
const blob = new Blob([source], { type: "image/svg+xml" });
|
|
962
|
+
const link = document.createElement("a");
|
|
963
|
+
link.href = URL.createObjectURL(blob);
|
|
964
|
+
link.download = document.title.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() + ".svg";
|
|
965
|
+
link.click();
|
|
966
|
+
URL.revokeObjectURL(link.href);
|
|
967
|
+
});
|
|
968
|
+
</script>
|
|
969
|
+
</body>
|
|
970
|
+
</html>
|
|
971
|
+
`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* @param {SummaryCard[]} summary
|
|
976
|
+
*/
|
|
977
|
+
function renderSummaryCards(summary) {
|
|
978
|
+
if (summary.length === 0) return "";
|
|
979
|
+
return ` <section class="summary-grid" aria-label="Diagram summary">
|
|
980
|
+
${summary.map((card) => ` <article class="summary-card summary-${card.type}">
|
|
981
|
+
<h2>${escapeHtml(card.title)}</h2>
|
|
982
|
+
<ul>${card.items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>
|
|
983
|
+
</article>`).join("\n")}
|
|
984
|
+
</section>`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @param {number} width
|
|
989
|
+
* @param {number} height
|
|
990
|
+
* @param {Box[]} boxes
|
|
991
|
+
* @param {Connector[]} connectors
|
|
992
|
+
*/
|
|
993
|
+
function manifestFrom(width, height, boxes, connectors) {
|
|
994
|
+
for(const connector of connectors) if(connector.points.some(([x,y])=>x<0||y<0)) qualityIssues.push({code:"connector-out-of-bounds",id:connector.id,message:`Connector "${connector.id}" has negative explicit coordinates.`});
|
|
995
|
+
const nodes = boxes.filter((box) => box.kind !== "group");
|
|
996
|
+
for (const box of boxes) if (box.x < 0 || box.y < 0) qualityIssues.push({code:"box-out-of-bounds",id:box.id,message:`Box "${box.id}" has negative coordinates.`});
|
|
997
|
+
for (let i=0;i<nodes.length;i++) for (let j=i+1;j<nodes.length;j++) if (overlaps(nodes[i],nodes[j])) qualityIssues.push({code:"box-overlap",id:nodes[i].id,message:`Nodes "${nodes[i].id}" and "${nodes[j].id}" overlap.`});
|
|
998
|
+
return {
|
|
999
|
+
canvas: finalCanvas.width ? finalCanvas : { width, height },
|
|
1000
|
+
boxes: boxes.map(({ id, x, y, width: boxWidth, height: boxHeight, label, kind }) => ({ id, x, y, width: boxWidth, height: boxHeight, label, kind })),
|
|
1001
|
+
groups: boxes.filter((box) => box.kind === "group").map((box) => ({ ...box })),
|
|
1002
|
+
connectors,
|
|
1003
|
+
labels: textBounds,
|
|
1004
|
+
coordinateSpace: "svg",
|
|
1005
|
+
scale: 1,
|
|
1006
|
+
measurement: "estimated",
|
|
1007
|
+
issues: qualityIssues,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* @param {Box[]} boxes
|
|
1013
|
+
* @param {"x" | "y"} axis
|
|
1014
|
+
*/
|
|
1015
|
+
function maxBoxExtent(boxes, axis) {
|
|
1016
|
+
const sizeKey = axis === "x" ? "width" : "height";
|
|
1017
|
+
return Math.max(0, ...boxes.map((box) => box[axis] + box[sizeKey]));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* @param {Box} from
|
|
1022
|
+
* @param {Box} to
|
|
1023
|
+
* @param {unknown} waypoints
|
|
1024
|
+
* @param {string} pointer
|
|
1025
|
+
* @returns {Array<[number, number]>}
|
|
1026
|
+
*/
|
|
1027
|
+
function architectureRoute(from, to, waypoints, pointer) {
|
|
1028
|
+
return routeWithWaypoints(from, to, waypoints, pointer);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* @param {Box} from
|
|
1033
|
+
* @param {Box} to
|
|
1034
|
+
* @param {unknown} waypoints
|
|
1035
|
+
* @param {string} pointer
|
|
1036
|
+
* @returns {Array<[number, number]>}
|
|
1037
|
+
*/
|
|
1038
|
+
function routeWithWaypoints(from, to, waypoints, pointer) {
|
|
1039
|
+
if (waypoints === undefined) return routeBoxes(from, to);
|
|
1040
|
+
if (!Array.isArray(waypoints)) throw usageError(pointer, "Expected array of [x, y] waypoints.");
|
|
1041
|
+
/** @type {Array<[number, number]>} */
|
|
1042
|
+
const parsed = waypoints.map((point, index) => {
|
|
1043
|
+
if (!Array.isArray(point) || point.length !== 2 || typeof point[0] !== "number" || typeof point[1] !== "number" || !Number.isFinite(point[0]) || !Number.isFinite(point[1])) {
|
|
1044
|
+
throw usageError(`${pointer}/${index}`, "Expected [x, y] number tuple.");
|
|
1045
|
+
}
|
|
1046
|
+
return [point[0], point[1]];
|
|
1047
|
+
});
|
|
1048
|
+
const result = routeConnector(from, to, routeObstacles.filter((box) => box.id !== from.id && box.id !== to.id), occupiedRoutes, parsed);
|
|
1049
|
+
qualityIssues.push(...result.issues);
|
|
1050
|
+
occupiedRoutes.push(result.points);
|
|
1051
|
+
return result.points;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* @param {string} type
|
|
1056
|
+
* @param {boolean} isWorkflow
|
|
1057
|
+
*/
|
|
1058
|
+
function defaultWorkflowWidth(type, isWorkflow) {
|
|
1059
|
+
if (!isWorkflow) return 132;
|
|
1060
|
+
if (type === "decision") return 92;
|
|
1061
|
+
if (type === "start" || type === "success" || type === "failure") return 128;
|
|
1062
|
+
return 132;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* @param {string} type
|
|
1067
|
+
* @param {boolean} isWorkflow
|
|
1068
|
+
*/
|
|
1069
|
+
function defaultWorkflowHeight(type, isWorkflow) {
|
|
1070
|
+
if (!isWorkflow) return 58;
|
|
1071
|
+
if (type === "decision") return 92;
|
|
1072
|
+
if (type === "start" || type === "success" || type === "failure") return 46;
|
|
1073
|
+
return 58;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* @param {string} type
|
|
1078
|
+
* @returns {"box" | "diamond" | "terminal"}
|
|
1079
|
+
*/
|
|
1080
|
+
function shapeForWorkflowType(type) {
|
|
1081
|
+
if (type === "decision") return "diamond";
|
|
1082
|
+
if (type === "start" || type === "success" || type === "failure") return "terminal";
|
|
1083
|
+
return "box";
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* @param {unknown} value
|
|
1088
|
+
* @param {number} fallback
|
|
1089
|
+
* @param {string} pointer
|
|
1090
|
+
*/
|
|
1091
|
+
function normalizeStep(value, fallback, pointer) {
|
|
1092
|
+
if (value === undefined) return String(fallback);
|
|
1093
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1094
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
1095
|
+
throw usageError(pointer, "Expected non-empty string or finite number.");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* @param {Box} box
|
|
1100
|
+
* @param {[number, number]} target
|
|
1101
|
+
* @returns {[number, number]}
|
|
1102
|
+
*/
|
|
1103
|
+
function edgePointToward(box, target) {
|
|
1104
|
+
const cx = centerX(box);
|
|
1105
|
+
const cy = centerY(box);
|
|
1106
|
+
const dx = target[0] - cx;
|
|
1107
|
+
const dy = target[1] - cy;
|
|
1108
|
+
if (Math.abs(dx) / Math.max(1, box.width) > Math.abs(dy) / Math.max(1, box.height)) {
|
|
1109
|
+
return [dx >= 0 ? box.x + box.width : box.x, cy];
|
|
1110
|
+
}
|
|
1111
|
+
return [cx, dy >= 0 ? box.y + box.height : box.y];
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* @param {unknown} legend
|
|
1116
|
+
* @param {Box[]} boxes
|
|
1117
|
+
* @returns {LegendItem[]}
|
|
1118
|
+
*/
|
|
1119
|
+
function normalizeLegend(legend, boxes) {
|
|
1120
|
+
if (legend === false) return [];
|
|
1121
|
+
if (legend !== undefined) {
|
|
1122
|
+
const items = requireObjectArray(legend, "/legend");
|
|
1123
|
+
return items.map((item, index) => ({
|
|
1124
|
+
label: requireString(item, "label", `/legend/${index}/label`),
|
|
1125
|
+
type: normalizeType(item.type, `/legend/${index}/type`),
|
|
1126
|
+
}));
|
|
1127
|
+
}
|
|
1128
|
+
const seen = new Set();
|
|
1129
|
+
/** @type {LegendItem[]} */
|
|
1130
|
+
const items = [];
|
|
1131
|
+
for (const box of boxes) {
|
|
1132
|
+
if (!seen.has(box.type)) {
|
|
1133
|
+
seen.add(box.type);
|
|
1134
|
+
items.push({ label: labelForType(box.type), type: box.type });
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return items;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* @param {LegendItem[]} items
|
|
1142
|
+
* @param {number} x
|
|
1143
|
+
* @param {number} y
|
|
1144
|
+
*/
|
|
1145
|
+
function renderArchitectureLegend(items, x, y, columns = 4) {
|
|
1146
|
+
if (items.length === 0) return "";
|
|
1147
|
+
const itemSvg = items.map((item, index) => {
|
|
1148
|
+
const itemX = x + (index % columns) * 200;
|
|
1149
|
+
const itemY = y + Math.floor(index / columns) * 72;
|
|
1150
|
+
const text = layoutText(item.label,160,12,16);
|
|
1151
|
+
return `<g class="node node-${item.type}"><rect x="${itemX}" y="${itemY + 16}" width="16" height="10" rx="2"/>${renderText("legend-"+index,"legend","group",item.label,text,itemX+24+text.width/2,itemY+14,"sublabel")}</g>`;
|
|
1152
|
+
}).join("\n");
|
|
1153
|
+
return `<g class="legend"><text x="${x}" y="${y}" class="label">Legend</text>\n${itemSvg}</g>`;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* @param {LegendItem[]} items
|
|
1158
|
+
* @param {number} x
|
|
1159
|
+
* @param {number} y
|
|
1160
|
+
*/
|
|
1161
|
+
function renderFlowLegend(items, x, y, columns = 4) {
|
|
1162
|
+
if (items.length === 0) return "";
|
|
1163
|
+
const itemSvg = items.map((item, index) => {
|
|
1164
|
+
const itemX = x + (index % columns) * 200;
|
|
1165
|
+
const itemY = y + Math.floor(index / columns) * 72;
|
|
1166
|
+
const text = layoutText(item.label,152,12,16);
|
|
1167
|
+
return `<g class="edge flow-${item.type}"><path d="M ${itemX} ${itemY + 22} L ${itemX + 28} ${itemY + 22}" marker-end="url(#arrow-flow-${item.type})"/>${renderText("flow-legend-"+index,"flow-legend","group",item.label,text,itemX+38+text.width/2,itemY+14,"sublabel")}</g>`;
|
|
1168
|
+
}).join("\n");
|
|
1169
|
+
return `<g class="flow-legend"><text x="${x}" y="${y}" class="label">Flow Legend</text>\n${itemSvg}</g>`;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* @param {string} type
|
|
1174
|
+
*/
|
|
1175
|
+
function labelForType(type) {
|
|
1176
|
+
return ({
|
|
1177
|
+
frontend: "Frontend",
|
|
1178
|
+
backend: "Backend",
|
|
1179
|
+
database: "Database",
|
|
1180
|
+
cloud: "Cloud",
|
|
1181
|
+
security: "Security",
|
|
1182
|
+
messagebus: "Message bus",
|
|
1183
|
+
external: "External",
|
|
1184
|
+
agent: "Agent",
|
|
1185
|
+
model: "Model",
|
|
1186
|
+
memory: "Memory",
|
|
1187
|
+
vectorstore: "Vector store",
|
|
1188
|
+
graphdb: "Graph DB",
|
|
1189
|
+
tool: "Tool",
|
|
1190
|
+
document: "Document",
|
|
1191
|
+
queue: "Queue",
|
|
1192
|
+
browser: "Browser",
|
|
1193
|
+
user: "User",
|
|
1194
|
+
gateway: "Gateway",
|
|
1195
|
+
})[type] ?? type;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* @param {string} flow
|
|
1200
|
+
*/
|
|
1201
|
+
function labelForFlow(flow) {
|
|
1202
|
+
return ({
|
|
1203
|
+
control: "Control",
|
|
1204
|
+
data: "Data",
|
|
1205
|
+
read: "Read",
|
|
1206
|
+
write: "Write",
|
|
1207
|
+
async: "Async",
|
|
1208
|
+
feedback: "Feedback",
|
|
1209
|
+
})[flow] ?? flow;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* @param {unknown} legend
|
|
1214
|
+
* @param {string[]} flows
|
|
1215
|
+
* @returns {LegendItem[]}
|
|
1216
|
+
*/
|
|
1217
|
+
function normalizeFlowLegend(legend, flows) {
|
|
1218
|
+
if (legend === false) return [];
|
|
1219
|
+
if (legend !== undefined) {
|
|
1220
|
+
const items = requireObjectArray(legend, "/flowLegend");
|
|
1221
|
+
return items.map((item, index) => ({
|
|
1222
|
+
label: requireString(item, "label", `/flowLegend/${index}/label`),
|
|
1223
|
+
type: normalizeFlow(item.type, `/flowLegend/${index}/type`) ?? "control",
|
|
1224
|
+
}));
|
|
1225
|
+
}
|
|
1226
|
+
if (flows.length < 2) return [];
|
|
1227
|
+
return flows.map((flow) => ({ label: labelForFlow(flow), type: flow }));
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* @param {unknown} summary
|
|
1232
|
+
* @returns {SummaryCard[]}
|
|
1233
|
+
*/
|
|
1234
|
+
function normalizeSummary(summary) {
|
|
1235
|
+
if (summary === undefined) return [];
|
|
1236
|
+
const cards = requireObjectArray(summary, "/summary").slice(0, 3);
|
|
1237
|
+
return cards.map((card, index) => {
|
|
1238
|
+
const items = requireArray(card.items, `/summary/${index}/items`);
|
|
1239
|
+
return {
|
|
1240
|
+
title: requireString(card, "title", `/summary/${index}/title`),
|
|
1241
|
+
type: normalizeType(card.type, `/summary/${index}/type`),
|
|
1242
|
+
items: items.map((item, itemIndex) => {
|
|
1243
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
1244
|
+
throw usageError(`/summary/${index}/items/${itemIndex}`, "Expected non-empty string.");
|
|
1245
|
+
}
|
|
1246
|
+
return item;
|
|
1247
|
+
}),
|
|
1248
|
+
};
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* @param {Box} from
|
|
1254
|
+
* @param {Box} to
|
|
1255
|
+
* @returns {Array<[number, number]>}
|
|
1256
|
+
*/
|
|
1257
|
+
function routeBoxes(from, to) {
|
|
1258
|
+
const result = routeConnector(from, to, routeObstacles.filter((box) => box.id !== from.id && box.id !== to.id), occupiedRoutes);
|
|
1259
|
+
qualityIssues.push(...result.issues);
|
|
1260
|
+
occupiedRoutes.push(result.points);
|
|
1261
|
+
return result.points;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* @param {Box} box
|
|
1266
|
+
*/
|
|
1267
|
+
function centerX(box) {
|
|
1268
|
+
return box.x + box.width / 2;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* @param {Box} box
|
|
1273
|
+
*/
|
|
1274
|
+
function centerY(box) {
|
|
1275
|
+
return box.y + box.height / 2;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* @param {string} value
|
|
1280
|
+
* @param {number} size
|
|
1281
|
+
*/
|
|
1282
|
+
function wrapLabel(value, size) {
|
|
1283
|
+
const words = value.split(/\s+/).filter(Boolean);
|
|
1284
|
+
/** @type {string[]} */
|
|
1285
|
+
const lines = [];
|
|
1286
|
+
let line = "";
|
|
1287
|
+
for (const word of words) {
|
|
1288
|
+
if ((line ? `${line} ${word}` : word).length > size) {
|
|
1289
|
+
if (line) lines.push(line);
|
|
1290
|
+
line = word;
|
|
1291
|
+
} else {
|
|
1292
|
+
line = line ? `${line} ${word}` : word;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (line) lines.push(line);
|
|
1296
|
+
return lines.length > 0 ? lines : [value];
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* @param {string} type
|
|
1301
|
+
*/
|
|
1302
|
+
function isSemanticNodeType(type) {
|
|
1303
|
+
return ["agent", "model", "memory", "vectorstore", "graphdb", "tool", "document", "queue", "browser", "user", "gateway"].includes(type);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* @param {unknown} visual
|
|
1308
|
+
* @param {string} pointer
|
|
1309
|
+
*/
|
|
1310
|
+
function normalizeVisualStyle(visual, pointer) {
|
|
1311
|
+
if (visual === undefined) return "default";
|
|
1312
|
+
assertObject(visual, pointer);
|
|
1313
|
+
const style = /** @type {Record<string, unknown>} */ (visual).style;
|
|
1314
|
+
if (style === undefined) return "default";
|
|
1315
|
+
if (typeof style !== "string" || !VISUAL_STYLES.has(style)) {
|
|
1316
|
+
throw usageError(`${pointer}/style`, `Unsupported visual style "${String(style)}".`);
|
|
1317
|
+
}
|
|
1318
|
+
return style;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* @param {unknown} value
|
|
1323
|
+
* @param {string} pointer
|
|
1324
|
+
*/
|
|
1325
|
+
function normalizeFlow(value, pointer) {
|
|
1326
|
+
if (value === undefined) return "";
|
|
1327
|
+
if (typeof value !== "string" || !FLOW_TYPES.has(value)) {
|
|
1328
|
+
throw usageError(pointer, `Unsupported flow "${String(value)}".`);
|
|
1329
|
+
}
|
|
1330
|
+
return value;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* @param {unknown} value
|
|
1335
|
+
* @param {string} pointer
|
|
1336
|
+
*/
|
|
1337
|
+
function normalizeType(value, pointer) {
|
|
1338
|
+
if (typeof value !== "string") return "external";
|
|
1339
|
+
if (!NODE_TYPES.has(value)) throw usageError(pointer, `Unsupported node type "${value}".`);
|
|
1340
|
+
return value;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* @param {unknown} value
|
|
1345
|
+
* @param {string} pointer
|
|
1346
|
+
*/
|
|
1347
|
+
function normalizeVariant(value, pointer) {
|
|
1348
|
+
if (value === undefined) return "default";
|
|
1349
|
+
if (typeof value !== "string" || !EDGE_VARIANTS.has(value)) {
|
|
1350
|
+
throw usageError(pointer, `Unsupported edge variant "${String(value)}".`);
|
|
1351
|
+
}
|
|
1352
|
+
return value;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* @param {unknown} value
|
|
1357
|
+
* @param {string} pointer
|
|
1358
|
+
*/
|
|
1359
|
+
function assertObject(value, pointer) {
|
|
1360
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1361
|
+
throw usageError(pointer, "Expected object.");
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* @param {unknown} value
|
|
1367
|
+
* @param {string} pointer
|
|
1368
|
+
* @returns {unknown[]}
|
|
1369
|
+
*/
|
|
1370
|
+
function requireArray(value, pointer) {
|
|
1371
|
+
if (!Array.isArray(value)) throw usageError(pointer, "Expected array.");
|
|
1372
|
+
return value;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* @param {unknown} value
|
|
1377
|
+
* @param {string} pointer
|
|
1378
|
+
* @returns {Array<Record<string, unknown>>}
|
|
1379
|
+
*/
|
|
1380
|
+
function requireObjectArray(value, pointer) {
|
|
1381
|
+
const items = requireArray(value, pointer);
|
|
1382
|
+
for (const [index, item] of items.entries()) {
|
|
1383
|
+
assertObject(item, `${pointer}/${index}`);
|
|
1384
|
+
}
|
|
1385
|
+
return /** @type {Array<Record<string, unknown>>} */ (items);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* @param {Record<string, unknown>} object
|
|
1390
|
+
* @param {string} key
|
|
1391
|
+
* @param {string} pointer
|
|
1392
|
+
*/
|
|
1393
|
+
function requireString(object, key, pointer) {
|
|
1394
|
+
const value = object[key];
|
|
1395
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1396
|
+
throw usageError(pointer, "Expected non-empty string.");
|
|
1397
|
+
}
|
|
1398
|
+
return value;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* @param {Record<string, unknown>} object
|
|
1403
|
+
* @param {string} key
|
|
1404
|
+
* @param {string} pointer
|
|
1405
|
+
*/
|
|
1406
|
+
function requireId(object, key, pointer) {
|
|
1407
|
+
const value = requireString(object, key, pointer);
|
|
1408
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(value)) {
|
|
1409
|
+
throw usageError(pointer, "Expected id to match ^[a-zA-Z][a-zA-Z0-9_-]*$.");
|
|
1410
|
+
}
|
|
1411
|
+
return value;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* @param {Record<string, unknown>} object
|
|
1416
|
+
* @param {string} key
|
|
1417
|
+
* @param {string} pointer
|
|
1418
|
+
*/
|
|
1419
|
+
function requireNumber(object, key, pointer) {
|
|
1420
|
+
const value = object[key];
|
|
1421
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1422
|
+
throw usageError(pointer, "Expected finite number.");
|
|
1423
|
+
}
|
|
1424
|
+
return value;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* @param {Set<string> | Map<string, unknown>} seen
|
|
1429
|
+
* @param {string} id
|
|
1430
|
+
* @param {string} pointer
|
|
1431
|
+
*/
|
|
1432
|
+
function ensureUnique(seen, id, pointer) {
|
|
1433
|
+
if (seen.has(id)) throw usageError(pointer, `Duplicate id "${id}".`);
|
|
1434
|
+
if (seen instanceof Set) seen.add(id);
|
|
1435
|
+
else seen.set(id, undefined);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* @param {string} pointer
|
|
1440
|
+
* @param {string} message
|
|
1441
|
+
*/
|
|
1442
|
+
function usageError(pointer, message) {
|
|
1443
|
+
return new Error(`${pointer}: ${message}`);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* @param {string} value
|
|
1448
|
+
*/
|
|
1449
|
+
function escapeHtml(value) {
|
|
1450
|
+
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[char] ?? char));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* @param {{ ok: boolean; type: string; input: string; output: string; manifest: string | null; nodes: number; connectors: number }} report
|
|
1455
|
+
* @param {string} format
|
|
1456
|
+
*/
|
|
1457
|
+
function printReport(report, format) {
|
|
1458
|
+
if (format === "json") {
|
|
1459
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
console.log(`Rendered ${report.type} technical diagram`);
|
|
1463
|
+
console.log(`- Output: ${report.output}`);
|
|
1464
|
+
if (report.manifest) console.log(`- Manifest: ${report.manifest}`);
|
|
1465
|
+
console.log(`- Nodes: ${report.nodes}`);
|
|
1466
|
+
console.log(`- Connectors: ${report.connectors}`);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* @typedef {{
|
|
1471
|
+
* id: string;
|
|
1472
|
+
* x: number;
|
|
1473
|
+
* y: number;
|
|
1474
|
+
* width: number;
|
|
1475
|
+
* height: number;
|
|
1476
|
+
* label: string;
|
|
1477
|
+
* sublabel: string;
|
|
1478
|
+
* type: string;
|
|
1479
|
+
* actor?: string;
|
|
1480
|
+
* step?: string;
|
|
1481
|
+
* kind?: string;
|
|
1482
|
+
* shape?: "box" | "diamond" | "terminal";
|
|
1483
|
+
* }} Box
|
|
1484
|
+
*
|
|
1485
|
+
* @typedef {{
|
|
1486
|
+
* id: string;
|
|
1487
|
+
* from: string;
|
|
1488
|
+
* to: string;
|
|
1489
|
+
* kind?: string;
|
|
1490
|
+
* self?: boolean;
|
|
1491
|
+
* points: Array<[number, number]>;
|
|
1492
|
+
* }} Connector
|
|
1493
|
+
*
|
|
1494
|
+
* @typedef {{
|
|
1495
|
+
* meta: { title: string; subtitle?: string };
|
|
1496
|
+
* lanes: Array<Record<string, unknown>>;
|
|
1497
|
+
* nodes?: Array<Record<string, unknown>>;
|
|
1498
|
+
* edges?: Array<Record<string, unknown>>;
|
|
1499
|
+
* states?: Array<Record<string, unknown>>;
|
|
1500
|
+
* transitions?: Array<Record<string, unknown>>;
|
|
1501
|
+
* summary?: Array<Record<string, unknown>>;
|
|
1502
|
+
* }} LaneDiagram
|
|
1503
|
+
*
|
|
1504
|
+
* @typedef {{
|
|
1505
|
+
* meta: { title: string; subtitle?: string };
|
|
1506
|
+
* participants: Array<Record<string, unknown>>;
|
|
1507
|
+
* messages: Array<Record<string, unknown>>;
|
|
1508
|
+
* }} SequenceDiagram
|
|
1509
|
+
*
|
|
1510
|
+
* @typedef {{
|
|
1511
|
+
* meta: { title: string; subtitle?: string };
|
|
1512
|
+
* stages: Array<Record<string, unknown>>;
|
|
1513
|
+
* nodes: Array<Record<string, unknown>>;
|
|
1514
|
+
* flows: Array<Record<string, unknown>>;
|
|
1515
|
+
* }} DataflowDiagram
|
|
1516
|
+
*
|
|
1517
|
+
* @typedef {{
|
|
1518
|
+
* meta: { title: string; subtitle?: string };
|
|
1519
|
+
* groups?: Array<Record<string, unknown>>;
|
|
1520
|
+
* nodes: Array<Record<string, unknown>>;
|
|
1521
|
+
* connections: Array<Record<string, unknown>>;
|
|
1522
|
+
* legend?: Array<Record<string, unknown>> | false;
|
|
1523
|
+
* flowLegend?: Array<Record<string, unknown>> | false;
|
|
1524
|
+
* summary?: Array<Record<string, unknown>>;
|
|
1525
|
+
* }} ArchitectureDiagram
|
|
1526
|
+
*
|
|
1527
|
+
* @typedef {{
|
|
1528
|
+
* label: string;
|
|
1529
|
+
* type: string;
|
|
1530
|
+
* }} LegendItem
|
|
1531
|
+
*
|
|
1532
|
+
* @typedef {{
|
|
1533
|
+
* title: string;
|
|
1534
|
+
* type: string;
|
|
1535
|
+
* items: string[];
|
|
1536
|
+
* }} SummaryCard
|
|
1537
|
+
*/
|