qgraphflow 0.0.6 → 0.0.7
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/README.md +119 -70
- package/docs/clients.de.md +15 -24
- package/docs/clients.es.md +15 -24
- package/docs/clients.ja.md +15 -24
- package/docs/clients.md +15 -24
- package/docs/clients.pt.md +15 -24
- package/docs/clients.ru.md +15 -24
- package/docs/clients.zh-CN.md +15 -24
- package/docs/readme/README.de.md +120 -71
- package/docs/readme/README.es.md +120 -71
- package/docs/readme/README.ja.md +120 -71
- package/docs/readme/README.pt.md +120 -71
- package/docs/readme/README.ru.md +120 -71
- package/docs/readme/README.zh-CN.md +106 -59
- package/examples/jeepay/README.md +23 -0
- package/examples/jeepay/capabilities.graph.json +270 -0
- package/examples/jeepay/class.graph.json +237 -0
- package/examples/jeepay/collection.graph.json +3057 -0
- package/examples/jeepay/dataflow.graph.json +212 -0
- package/examples/jeepay/deployment.graph.json +222 -0
- package/examples/jeepay/engineering.graph.json +277 -0
- package/examples/jeepay/er.graph.json +482 -0
- package/examples/jeepay/flowchart.graph.json +312 -0
- package/examples/jeepay/relations.graph.json +289 -0
- package/examples/jeepay/sequence.graph.json +355 -0
- package/examples/jeepay/source.json +95 -0
- package/examples/jeepay/state.graph.json +175 -0
- package/examples/jeepay/usecase.graph.json +222 -0
- package/package.json +14 -3
- package/skills/q-flow/SKILL.md +28 -20
- package/skills/q-flow/agents/openai.yaml +1 -1
- package/skills/q-flow/assets/viewer/package.json +1 -1
- package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
- package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
- package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
- package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
- package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
- package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
- package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
- package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
- package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
- package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
- package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
- package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
- package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
- package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
- package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
- package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
- package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
- package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
- package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
- package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
- package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
- package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
- package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
- package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
- package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
- package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
- package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
- package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
- package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
- package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
- package/skills/q-flow/assets/viewer-dist/index.html +30 -28
- package/skills/q-flow/references/evidence-sources.md +7 -5
- package/skills/q-flow/references/graph-common.md +34 -34
- package/skills/q-flow/references/graph-schema.md +28 -7
- package/skills/q-flow/references/guided-intake.md +51 -71
- package/skills/q-flow/references/layout-routing.md +47 -0
- package/skills/q-flow/references/types/architecture.md +42 -22
- package/skills/q-flow/references/types/class.md +9 -2
- package/skills/q-flow/references/types/dataflow.md +11 -4
- package/skills/q-flow/references/types/deployment.md +11 -3
- package/skills/q-flow/references/types/er.md +8 -1
- package/skills/q-flow/references/types/flowchart.md +12 -5
- package/skills/q-flow/references/types/sequence.md +20 -16
- package/skills/q-flow/references/types/state.md +10 -3
- package/skills/q-flow/references/types/usecase.md +6 -0
- package/skills/q-flow/references/viewer-development.md +37 -24
- package/skills/q-flow/references/visual-contract.md +12 -6
- package/skills/q-flow/scripts/compile-layout.mjs +85 -102
- package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
- package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
- package/skills/q-flow/scripts/validate-graph.mjs +38 -21
- package/examples/order-flow.graph.json +0 -94
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { affectedRouteIds } from './route-clearance.js';
|
|
2
|
+
import { LAYOUT_VERSION } from './layout-policy.js';
|
|
3
|
+
import { presentationGraph, mergePresentationEdges } from './presentation-graph.js';
|
|
4
|
+
import { routeOrthogonal } from './orthogonal-routing.js';
|
|
5
|
+
import { layoutText } from './text-layout.js';
|
|
6
|
+
import { isArchitectureOverview } from './view-identity.js';
|
|
7
|
+
import { OVERVIEW, OVERVIEW_TONES, overviewPalette } from './architecture-overview-theme.js';
|
|
8
|
+
|
|
9
|
+
export const overviewSections = graph => {
|
|
10
|
+
const walk = (sections, depth = 0) => (Array.isArray(sections) && depth <= 16 ? sections : []).filter(section => section && typeof section === 'object').flatMap(section => [section, ...walk((Array.isArray(section.items) ? section.items : []).filter(item => item?.mode), depth + 1)]);
|
|
11
|
+
return walk(graph.layout?.sections);
|
|
12
|
+
};
|
|
13
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim());
|
|
15
|
+
export const hasOverviewContent = node => node.overviewText !== undefined || node.badges !== undefined || node.overviewTone !== undefined;
|
|
16
|
+
|
|
17
|
+
export function validateOverview(graph, { inputOnly = true, validateAnchor, evidenceKinds } = {}) {
|
|
18
|
+
const errors = [], overview = isArchitectureOverview(graph);
|
|
19
|
+
const tone = (value, path) => { if (value !== undefined && !OVERVIEW_TONES.includes(value)) errors.push(`${path} is unsupported`); };
|
|
20
|
+
const anchor = (source, path) => {
|
|
21
|
+
if (!object(source)) errors.push(`${path} must be an object`);
|
|
22
|
+
else { validateAnchor?.(source, path, errors); if (source.kind !== undefined && !evidenceKinds?.has(source.kind)) errors.push(`${path}.kind is unsupported`); }
|
|
23
|
+
};
|
|
24
|
+
for (const node of graph.nodes ?? []) {
|
|
25
|
+
tone(node.overviewTone, `node ${node.id}.overviewTone`);
|
|
26
|
+
if (node.overviewAccent !== undefined && typeof node.overviewAccent !== 'boolean') errors.push(`node ${node.id}.overviewAccent must be boolean`);
|
|
27
|
+
if (node.overviewText !== undefined && !strings(node.overviewText)) errors.push(`node ${node.id}.overviewText must contain non-empty strings`);
|
|
28
|
+
if (node.badges !== undefined) {
|
|
29
|
+
if (!Array.isArray(node.badges)) errors.push(`node ${node.id}.badges must be an array`);
|
|
30
|
+
else for (const [i, badge] of node.badges.entries()) {
|
|
31
|
+
const path = `node ${node.id}.badges[${i}]`;
|
|
32
|
+
if (!object(badge) || typeof badge.label !== 'string' || !badge.label.trim() || !['status', 'version', 'requirement'].includes(badge.role) || !evidenceKinds?.has(badge.evidence)) errors.push(`${path} needs label, role (status/version/requirement) and evidence`);
|
|
33
|
+
if (badge?.source !== undefined) anchor(badge.source, `${path}.source`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!overview) {
|
|
38
|
+
if (graph.layout?.sections !== undefined) errors.push('layout.sections requires an architecture overview');
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(graph.layout?.sections) || !graph.layout.sections.length) return [...errors, 'architecture overview requires non-empty layout.sections'];
|
|
42
|
+
const nodeMap = new Map(graph.nodes.map(node => [node.id, node]));
|
|
43
|
+
const groups = new Map((graph.groups ?? []).map(group => [group.id, group]));
|
|
44
|
+
const seen = new Set(), sectionIds = new Set(), referencedGroups = new Set();
|
|
45
|
+
const addNode = id => { if (!nodeMap.has(id)) errors.push(`section nodeId does not name a node: ${id}`); else if (seen.has(id)) errors.push(`section repeats node ${id}`); else seen.add(id); };
|
|
46
|
+
const addGroup = (id, ancestry = new Set()) => {
|
|
47
|
+
if (!groups.has(id)) { errors.push(`section groupId does not name a group: ${id}`); return; }
|
|
48
|
+
if (ancestry.has(id) || referencedGroups.has(id)) { errors.push(`section repeats or cycles group ${id}`); return; }
|
|
49
|
+
referencedGroups.add(id); const path = new Set([...ancestry, id]);
|
|
50
|
+
graph.nodes.filter(node => node.groupId === id).forEach(node => addNode(node.id));
|
|
51
|
+
[...groups.values()].filter(group => group.parentId === id).forEach(group => addGroup(group.id, path));
|
|
52
|
+
};
|
|
53
|
+
const visit = (section, depth = 0) => {
|
|
54
|
+
if (!object(section) || depth > 16) { errors.push('invalid section or nesting exceeds 16 levels'); return; }
|
|
55
|
+
if (typeof section.id !== 'string' || !section.id.trim() || sectionIds.has(section.id) || nodeMap.has(section.id) || groups.has(section.id)) errors.push(`section.id must be unique: ${section.id}`);
|
|
56
|
+
sectionIds.add(section.id);
|
|
57
|
+
if (!['stack', 'grid', 'row', 'note'].includes(section.mode)) errors.push(`section ${section.id}.mode is unsupported`);
|
|
58
|
+
if (typeof section.title !== 'string' || !section.title.trim()) errors.push(`section ${section.id}.title is required`);
|
|
59
|
+
tone(section.tone, `section ${section.id}.tone`);
|
|
60
|
+
if (section.frame !== undefined && !['solid', 'dashed', 'none'].includes(section.frame)) errors.push(`section ${section.id}.frame is unsupported`);
|
|
61
|
+
if (section.columns !== undefined && (!Number.isInteger(section.columns) || section.columns < 1 || section.columns > 16 || section.mode !== 'grid')) errors.push(`section ${section.id}.columns requires a grid and an integer from 1 to 16`);
|
|
62
|
+
if (section.weights !== undefined && (section.mode !== 'row' || !Array.isArray(section.weights) || section.weights.length !== section.items?.length || section.weights.some(weight => !Number.isFinite(weight) || weight <= 0))) errors.push(`section ${section.id}.weights requires a row and one positive finite weight per item`);
|
|
63
|
+
if (section.text !== undefined && !strings(section.text)) errors.push(`section ${section.id}.text must contain non-empty strings`);
|
|
64
|
+
if (section.source !== undefined) anchor(section.source, `section ${section.id}.source`);
|
|
65
|
+
if (section.detailOf !== undefined && !nodeMap.has(section.detailOf)) errors.push(`section ${section.id}.detailOf does not name a node`);
|
|
66
|
+
if (!inputOnly && (!object(section.position) || !object(section.size) || !Number.isFinite(section.position.x) || !Number.isFinite(section.position.y) || section.position.x < 0 || section.position.y < 0 || !Number.isFinite(section.size.width) || !Number.isFinite(section.size.height) || section.size.width <= 0 || section.size.height <= 0)) errors.push(`section ${section.id} requires finite geometry`);
|
|
67
|
+
if (section.mode === 'note') {
|
|
68
|
+
if (!strings(section.text) || !section.text.length || (section.items?.length ?? 0)) errors.push(`note ${section.id} requires text and no items`);
|
|
69
|
+
} else if (!Array.isArray(section.items) || !section.items.length) errors.push(`section ${section.id}.items must be non-empty`);
|
|
70
|
+
for (const item of Array.isArray(section.items) ? section.items : []) {
|
|
71
|
+
if (!object(item)) { errors.push(`section ${section.id} item must be an object`); continue; }
|
|
72
|
+
const refs = ['nodeId', 'groupId', 'mode'].filter(key => Object.hasOwn(item, key));
|
|
73
|
+
if (refs.length !== 1) errors.push(`section ${section.id} item needs exactly one nodeId, groupId or nested section`);
|
|
74
|
+
else if (item.nodeId !== undefined) addNode(item.nodeId);
|
|
75
|
+
else if (item.groupId !== undefined) addGroup(item.groupId);
|
|
76
|
+
else visit(item, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
graph.layout.sections.forEach(section => visit(section));
|
|
80
|
+
for (const id of nodeMap.keys()) if (!seen.has(id)) errors.push(`overview node ${id} is missing from sections`);
|
|
81
|
+
return errors;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function overviewCardLayout(node, width = node.size.width) {
|
|
85
|
+
const p = node.overviewTone === 'plain' ? 0 : OVERVIEW.cardPadding, inner = width - p * 2, badges = [];
|
|
86
|
+
const badgeWidth = node.badges?.length ? Math.min(inner * .45, Math.max(...node.badges.map(badge => layoutText(badge.label, inner * .45 - 16, OVERVIEW.badge, OVERVIEW.badgeLine).width + 16))) : 0;
|
|
87
|
+
let badgeY = p;
|
|
88
|
+
for (const badge of node.badges ?? []) {
|
|
89
|
+
const label = layoutText(badge.label, badgeWidth - 16, OVERVIEW.badge, OVERVIEW.badgeLine), w = Math.max(48, label.width + 16), h = label.height + 6;
|
|
90
|
+
badges.push({ ...badge, x: width - p - w, y: badgeY, width: w, height: h, lines: label.lines }); badgeY += h + 6;
|
|
91
|
+
}
|
|
92
|
+
const title = layoutText(node.label, inner - (badgeWidth ? badgeWidth + 10 : 0), OVERVIEW.title, OVERVIEW.titleLine);
|
|
93
|
+
const lines = title.lines.map((text, i) => ({ text, x: p, y: p + OVERVIEW.title + i * OVERVIEW.titleLine, role: 'title' }));
|
|
94
|
+
const inlineSubtitle = node.subtitle && title.lines.length === 1 && layoutText(node.subtitle, Infinity, OVERVIEW.body, OVERVIEW.bodyLine).width + title.width + 12 <= inner - (badgeWidth ? badgeWidth + 10 : 0);
|
|
95
|
+
if (inlineSubtitle) lines.push({ text: node.subtitle, x: p + title.width + 12, y: p + OVERVIEW.title, role: 'body' });
|
|
96
|
+
let cursor = Math.max(p + title.height, badges.length ? badgeY - 6 : 0);
|
|
97
|
+
const paragraphs = width > 1000 ? [(node.overviewText ?? []).join(' ')] : node.overviewText ?? [];
|
|
98
|
+
for (const paragraph of [inlineSubtitle ? '' : node.subtitle, ...paragraphs].filter(Boolean)) {
|
|
99
|
+
cursor += 6; const content = layoutText(paragraph, inner, OVERVIEW.body, OVERVIEW.bodyLine);
|
|
100
|
+
lines.push(...content.lines.map((text, i) => ({ text, x: p, y: cursor + OVERVIEW.body + i * OVERVIEW.bodyLine, role: 'body' }))); cursor += content.height;
|
|
101
|
+
}
|
|
102
|
+
return { lines, badges, minHeight: cursor + p };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sectionPadding = section => section.frame === 'none' ? 0 : OVERVIEW.padding;
|
|
106
|
+
const sectionContentHeight = (section, width) => {
|
|
107
|
+
const inner = width - sectionPadding(section) * 2;
|
|
108
|
+
return sectionPadding(section) + layoutText(section.title, inner, OVERVIEW.sectionTitle, OVERVIEW.sectionLine).height + (section.text ?? []).reduce((sum, paragraph) => sum + 8 + layoutText(paragraph, inner, OVERVIEW.body, OVERVIEW.bodyLine).height, 0);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// A title occupies its measured lines, not an invisible wall across the whole band.
|
|
112
|
+
// Routing and geometry checks share these boxes with the section's SVG typography.
|
|
113
|
+
export function sectionTextBoxes(section) {
|
|
114
|
+
if (section.mode === 'note') return [{ ...section.position, ...section.size }];
|
|
115
|
+
const p = sectionPadding(section), x = section.position.x + p, width = section.size.width - p * 2;
|
|
116
|
+
const title = layoutText(section.title, width, OVERVIEW.sectionTitle, OVERVIEW.sectionLine);
|
|
117
|
+
const boxes = title.lines.map((line, i) => ({ x, y: section.position.y + p + i * OVERVIEW.sectionLine,
|
|
118
|
+
width: layoutText(line, Infinity, OVERVIEW.sectionTitle, OVERVIEW.sectionLine).width, height: OVERVIEW.sectionLine }));
|
|
119
|
+
let cursor = section.position.y + p + title.height;
|
|
120
|
+
for (const paragraph of section.text ?? []) {
|
|
121
|
+
cursor += 8;
|
|
122
|
+
const body = layoutText(paragraph, width, OVERVIEW.body, OVERVIEW.bodyLine);
|
|
123
|
+
boxes.push(...body.lines.map((line, i) => ({ x, y: cursor + i * OVERVIEW.bodyLine,
|
|
124
|
+
width: layoutText(line, Infinity, OVERVIEW.body, OVERVIEW.bodyLine).width, height: OVERVIEW.bodyLine })));
|
|
125
|
+
cursor += body.height;
|
|
126
|
+
}
|
|
127
|
+
return boxes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The same synchronous layout runs in Node and in the offline Viewer. Sections are presentation, never ownership.
|
|
131
|
+
export function layoutArchitectureOverview(input, { width = OVERVIEW.width, gap = OVERVIEW.cardGap, gaps = {}, bandGaps = {} } = {}) {
|
|
132
|
+
const graph = structuredClone(input), nodes = new Map(graph.nodes.map(node => [node.id, node]));
|
|
133
|
+
const groups = new Map((graph.groups ?? []).map(group => [group.id, group]));
|
|
134
|
+
const nodeHeight = (node, w) => overviewCardLayout(node, w).minHeight;
|
|
135
|
+
const groupSection = id => { const group = groups.get(id); return { id, title: group.label, mode: 'grid', group: true, items: [...(graph.groups ?? []).filter(child => child.parentId === id).map(child => ({ groupId: child.id })), ...graph.nodes.filter(node => node.groupId === id).map(node => ({ nodeId: node.id }))] }; };
|
|
136
|
+
const idsOf = item => item.nodeId ? [item.nodeId] : (item.groupId ? groupSection(item.groupId) : item).items?.flatMap(idsOf) ?? [];
|
|
137
|
+
const measure = (item, w) => {
|
|
138
|
+
if (item.nodeId) return { item, width: w, height: nodeHeight(nodes.get(item.nodeId), w) };
|
|
139
|
+
const section = item.groupId ? groupSection(item.groupId) : item, p = sectionPadding(section), localGap = gaps[section.id] ?? gap;
|
|
140
|
+
const top = sectionContentHeight(section, w) + (section.mode === 'note' ? 0 : 24);
|
|
141
|
+
if (section.mode === 'note') return { item: section, width: w, height: top + p };
|
|
142
|
+
const columns = section.mode === 'row' ? section.items.length : section.mode === 'grid' ? Math.min(section.items.length, section.columns ?? Math.max(1, Math.floor((w - p * 2 + localGap) / (300 + localGap)))) : 1;
|
|
143
|
+
const rows = [];
|
|
144
|
+
for (let i = 0; i < section.items.length; i += columns) {
|
|
145
|
+
const items = section.items.slice(i, i + columns), weights = section.mode === 'row' && section.weights || items.map(() => 1), total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
146
|
+
const available = w - p * 2 - localGap * (items.length - 1);
|
|
147
|
+
const row = items.map((child, j) => measure(child, available * weights[j] / total)), height = Math.max(...row.map(child => child.height));
|
|
148
|
+
// Inventory rows align both edges; parallel regions share a full-height support boundary.
|
|
149
|
+
for (const child of row) if (section.mode === 'grid' && child.item.nodeId || section.mode === 'row') child.height = height;
|
|
150
|
+
rows.push(row);
|
|
151
|
+
}
|
|
152
|
+
// Labeled dependencies need a small explicit connector lane. Unconnected inventory grids retain reference spacing.
|
|
153
|
+
const owned = new Set(idsOf(section)), connected = graph.edges.some(edge => owned.has(edge.source) && owned.has(edge.target));
|
|
154
|
+
const rowGap = connected ? Math.max(localGap, OVERVIEW.connectedGap) : localGap;
|
|
155
|
+
return { item: section, width: w, height: top + rows.reduce((sum, row) => sum + Math.max(...row.map(child => child.height)), 0) + rowGap * Math.max(0, rows.length - 1) + p, top, rows, rowGap, gap: localGap, padding: p };
|
|
156
|
+
};
|
|
157
|
+
const place = (plan, x, y) => {
|
|
158
|
+
if (plan.item.nodeId) { const node = nodes.get(plan.item.nodeId); node.position = { x, y }; node.size = { width: plan.width, height: plan.height }; return; }
|
|
159
|
+
const section = plan.item; section.position = { x, y }; section.size = { width: plan.width, height: plan.height };
|
|
160
|
+
if (section.group) Object.assign(groups.get(section.id), { position: section.position, size: section.size });
|
|
161
|
+
let rowY = y + plan.top;
|
|
162
|
+
for (const row of plan.rows ?? []) { let columnX = x + plan.padding; for (const child of row) { place(child, columnX, rowY); columnX += child.width + plan.gap; } rowY += Math.max(...row.map(child => child.height)) + plan.rowGap; }
|
|
163
|
+
};
|
|
164
|
+
const minimumWidth = item => {
|
|
165
|
+
if (item.nodeId) return 300;
|
|
166
|
+
const section = item.groupId ? groupSection(item.groupId) : item, p = sectionPadding(section), localGap = gaps[section.id] ?? gap;
|
|
167
|
+
if (section.mode === 'note') return 300 + p * 2;
|
|
168
|
+
const sizes = section.items.map(minimumWidth);
|
|
169
|
+
if (section.mode === 'row') {
|
|
170
|
+
const weights = section.weights ?? sizes.map(() => 1);
|
|
171
|
+
return p * 2 + Math.max(...sizes.map((size, i) => size / weights[i])) * weights.reduce((sum, weight) => sum + weight, 0) + gap * (sizes.length - 1);
|
|
172
|
+
}
|
|
173
|
+
if (section.columns) return p * 2 + Math.min(section.columns, section.items.length) * 176 + gap * (Math.min(section.columns, section.items.length) - 1);
|
|
174
|
+
return p * 2 + Math.max(...sizes);
|
|
175
|
+
};
|
|
176
|
+
width = Math.max(width, ...graph.layout.sections.map(minimumWidth));
|
|
177
|
+
let y = OVERVIEW.margin;
|
|
178
|
+
for (const section of graph.layout.sections) { const plan = measure(section, width); place(plan, OVERVIEW.margin, y); y += plan.height + Math.max(bandGaps[section.id] ?? gap, OVERVIEW.bandGap); }
|
|
179
|
+
const bounds = group => {
|
|
180
|
+
const children = [...graph.nodes.filter(node => node.groupId === group.id), ...(graph.groups ?? []).filter(child => child.parentId === group.id).map(child => { bounds(child); return child; })];
|
|
181
|
+
if (!children.length) throw new Error(`Empty ownership group ${group.id}`);
|
|
182
|
+
const x = Math.min(...children.map(child => child.position.x)) - OVERVIEW.padding, y = Math.min(...children.map(child => child.position.y)) - layoutText(group.label, width, 20, 26).height - OVERVIEW.padding;
|
|
183
|
+
const right = Math.max(...children.map(child => child.position.x + child.size.width)) + OVERVIEW.padding, bottom = Math.max(...children.map(child => child.position.y + child.size.height)) + OVERVIEW.padding;
|
|
184
|
+
group.position = { x: Math.max(0, x), y: Math.max(0, y) }; group.size = { width: right - group.position.x, height: bottom - group.position.y };
|
|
185
|
+
};
|
|
186
|
+
for (const group of graph.groups ?? []) if (!group.parentId && !overviewSections(graph).some(section => section.items?.some(item => item.groupId === group.id))) bounds(group);
|
|
187
|
+
graph.layout.version = LAYOUT_VERSION; graph.layout.direction = 'down'; graph.layout.strategy = 'architecture-overview-v2';
|
|
188
|
+
for (const edge of graph.edges) delete edge.route;
|
|
189
|
+
return routeOverview(graph);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Local maintenance keeps every existing slot and the legacy version. Oversized edits fail the same
|
|
193
|
+
// quality gate as drags, so the Viewer can retain the last valid canvas and the user's text draft.
|
|
194
|
+
export function maintainArchitectureOverview(input, reference, accept) {
|
|
195
|
+
const graph = structuredClone(input);
|
|
196
|
+
for (const node of graph.nodes) {
|
|
197
|
+
const old = reference.nodes.find(item => item.id === node.id);
|
|
198
|
+
if (JSON.stringify(node) !== JSON.stringify(old)) node.size.height = Math.max(node.size.height, overviewCardLayout(node).minHeight);
|
|
199
|
+
}
|
|
200
|
+
const installSlots = (oldIds, newIds) => {
|
|
201
|
+
if (oldIds.join('\0') === newIds.join('\0')) return;
|
|
202
|
+
const slots = oldIds.map(id => reference.nodes.find(node => node.id === id));
|
|
203
|
+
newIds.forEach((id,i) => { const node = graph.nodes.find(node => node.id === id); node.position = { ...slots[i].position }; });
|
|
204
|
+
};
|
|
205
|
+
for (const section of overviewSections(graph)) {
|
|
206
|
+
const old = overviewSections(reference).find(item => item.id === section.id);
|
|
207
|
+
if (old) installSlots((old.items ?? []).filter(item => item.nodeId).map(item => item.nodeId), (section.items ?? []).filter(item => item.nodeId).map(item => item.nodeId));
|
|
208
|
+
}
|
|
209
|
+
for (const group of graph.groups ?? []) installSlots(reference.nodes.filter(n=>n.groupId===group.id).map(n=>n.id),graph.nodes.filter(n=>n.groupId===group.id).map(n=>n.id));
|
|
210
|
+
const edgeIds = affectedRouteIds(reference, graph);
|
|
211
|
+
const routed = routeOrthogonal(presentationGraph(graph), { edgeIds, passes: 2, accept });
|
|
212
|
+
graph.edges = mergePresentationEdges(graph, routed.graph);
|
|
213
|
+
accept(graph, { strictEdgeIds: edgeIds });
|
|
214
|
+
return graph;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const overviewLayoutReports = new WeakMap();
|
|
218
|
+
export function fitArchitectureOverview(input, accept) {
|
|
219
|
+
const gaps = {}, bandGaps = {}, attempts = [];
|
|
220
|
+
let last;
|
|
221
|
+
for (let evaluation = 0; evaluation < 60; evaluation++) {
|
|
222
|
+
try {
|
|
223
|
+
const graph = layoutArchitectureOverview(input, { gaps, bandGaps });
|
|
224
|
+
accept(graph);
|
|
225
|
+
overviewLayoutReports.set(graph, { evaluations: evaluation + 1, budget: 60, gaps: { ...gaps }, bandGaps: { ...bandGaps }, attempts, termination: 'valid-local-spacing', provenImpossible: false });
|
|
226
|
+
return graph;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
last = error;
|
|
229
|
+
const ids = new Set([...(error.elementIds ?? []), ...(error.diagnostics ?? []).flatMap(d => d.elementIds)]);
|
|
230
|
+
for (const edge of input.edges) if (ids.has(edge.id)) { ids.add(edge.source); ids.add(edge.target); }
|
|
231
|
+
const all = overviewSections(input);
|
|
232
|
+
const descendants = section => (section.items ?? []).flatMap(item => item.nodeId ? [item.nodeId] : item.groupId ? input.nodes.filter(n => n.groupId === item.groupId).map(n => n.id) : descendants(item));
|
|
233
|
+
const affected = all.filter(section => descendants(section).some(id => ids.has(id)));
|
|
234
|
+
const leaves = affected.filter(section => !affected.some(child => child !== section && (section.items ?? []).includes(child)));
|
|
235
|
+
if (!leaves.length) throw error;
|
|
236
|
+
let changed = false;
|
|
237
|
+
for (const section of leaves) {
|
|
238
|
+
const before = gaps[section.id] ?? OVERVIEW.cardGap;
|
|
239
|
+
if (before >= 200) continue;
|
|
240
|
+
gaps[section.id] = before + 16; changed = true;
|
|
241
|
+
}
|
|
242
|
+
// Only the corridor following a source band grows; unrelated card grids keep their gap.
|
|
243
|
+
for (const section of input.layout.sections) if (descendants(section).some(id => ids.has(id))) {
|
|
244
|
+
const before = bandGaps[section.id] ?? OVERVIEW.bandGap;
|
|
245
|
+
if (before < 200) { bandGaps[section.id] = before + 16; changed = true; }
|
|
246
|
+
}
|
|
247
|
+
attempts.push({ elementIds: [...ids], reason: error.routingReason ?? 'quality', gaps: { ...gaps }, bandGaps: { ...bandGaps } });
|
|
248
|
+
if (!changed) break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
throw Object.assign(last, { routingReport: { attempts, termination: 'local-spacing-budget', provenImpossible: false } });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function reorderOverview(graph, id, position) {
|
|
255
|
+
const next = structuredClone(graph), target = next.nodes.find(node => node.id === id);
|
|
256
|
+
if (!target) return next;
|
|
257
|
+
const list = overviewSections(next).find(section => section.items?.some(item => item.nodeId === id));
|
|
258
|
+
if (list) {
|
|
259
|
+
const peers = list.items.filter(item => item.nodeId).map(item => next.nodes.find(node => node.id === item.nodeId));
|
|
260
|
+
const nearest = peers.sort((a, b) => Math.hypot(a.position.x - position.x, a.position.y - position.y) - Math.hypot(b.position.x - position.x, b.position.y - position.y))[0];
|
|
261
|
+
if (nearest && nearest.id !== id) { const from = list.items.findIndex(item => item.nodeId === id), to = list.items.findIndex(item => item.nodeId === nearest.id); list.items.splice(to, 0, list.items.splice(from, 1)[0]); }
|
|
262
|
+
} else if (target.groupId) {
|
|
263
|
+
const peers = next.nodes.filter(node => node.groupId === target.groupId), nearest = peers.sort((a, b) => Math.hypot(a.position.x - position.x, a.position.y - position.y) - Math.hypot(b.position.x - position.x, b.position.y - position.y))[0];
|
|
264
|
+
if (nearest && nearest.id !== id) { const from = next.nodes.findIndex(node => node.id === id), to = next.nodes.findIndex(node => node.id === nearest.id); next.nodes.splice(to, 0, next.nodes.splice(from, 1)[0]); }
|
|
265
|
+
}
|
|
266
|
+
return next;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const escape = value => String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
270
|
+
const svgText = (x, y, value, role, style = '') => `<text x="${x}" y="${y}" class="${role}"${style ? ` style="${style}"` : ''}>${escape(value)}</text>`;
|
|
271
|
+
export function overviewCardSvg(node, x, y, palette) {
|
|
272
|
+
const colors = overviewPalette(palette), tone = colors[node.overviewTone ?? 'subtle'] ?? colors.subtle, layout = overviewCardLayout(node);
|
|
273
|
+
const plain = node.overviewTone === 'plain';
|
|
274
|
+
const gradientId = `overview-card-${[...node.id].map(c => c.codePointAt(0).toString(16)).join('-')}`;
|
|
275
|
+
const gradient = !plain && tone.band && node.size.width > 1000 ? `<defs><linearGradient id="${gradientId}"><stop stop-color="${tone.band[0]}"/><stop offset="1" stop-color="${tone.band[1]}"/></linearGradient></defs>` : '';
|
|
276
|
+
const frame = `<rect class="node-surface" x="${x}" y="${y}" width="${node.size.width}" height="${node.size.height}" rx="${OVERVIEW.cardRadius}" fill="${plain ? 'none' : gradient ? `url(#${gradientId})` : tone.fill}" stroke="${plain ? 'none' : tone.stroke}" stroke-width="1.5"/>`;
|
|
277
|
+
const accent = node.overviewAccent && tone.accent ? `<path d="M${x + 5} ${y + 10}V${y + node.size.height - 10}" stroke="${tone.accent}" stroke-width="5" stroke-linecap="round"/>` : '';
|
|
278
|
+
const text = layout.lines.map(line => svgText(x + line.x, y + line.y, line.text, line.role, `fill:${line.role === 'title' ? colors.ink : colors.body};font-size:${line.role === 'title' ? OVERVIEW.title : OVERVIEW.body}px;font-weight:${line.role === 'title' ? 700 : 400}`)).join('');
|
|
279
|
+
return gradient + frame + accent + text + layout.badges.map(badge => {
|
|
280
|
+
const done = /^(已实现|已完成|完成|implemented|ready|done|complete)$/i.test(badge.label), badgeTone = badge.role === 'version' ? { fill: tone.accent ?? colors.blue.accent, ink: colors.paper } : badge.role === 'status' && done || badge.role === 'requirement' && /^(必选|required|mandatory)$/i.test(badge.label) ? colors.success : colors.badge;
|
|
281
|
+
return `<g data-badge-role="${badge.role}"><rect x="${x + badge.x}" y="${y + badge.y}" width="${badge.width}" height="${badge.height}" rx="8" fill="${badgeTone.fill}"/>${badge.lines.map((line, i) => svgText(x + badge.x + 8, y + badge.y + 3 + OVERVIEW.badge + i * OVERVIEW.badgeLine, line, 'meta', `fill:${badgeTone.ink};font-size:${OVERVIEW.badge}px;font-weight:600`)).join('')}</g>`;
|
|
282
|
+
}).join('');
|
|
283
|
+
}
|
|
284
|
+
export function sectionSvg(section, palette, offsetX = 0, offsetY = 0) {
|
|
285
|
+
const colors = overviewPalette(palette), tone = colors[section.tone ?? 'white'] ?? colors.white;
|
|
286
|
+
const x = section.position.x + offsetX, y = section.position.y + offsetY, w = section.size.width, p = sectionPadding(section);
|
|
287
|
+
const heading = layoutText(section.title, w - p * 2, OVERVIEW.sectionTitle, OVERVIEW.sectionLine); let cursor = y + p + heading.height;
|
|
288
|
+
let body = '';
|
|
289
|
+
for (const paragraph of section.text ?? []) { cursor += 8; const lines = layoutText(paragraph, w - p * 2, OVERVIEW.body, OVERVIEW.bodyLine); body += lines.lines.map((line, i) => svgText(x + p, cursor + OVERVIEW.body + i * OVERVIEW.bodyLine, line, 'body', `fill:${colors.body};font-size:${OVERVIEW.body}px`)).join(''); cursor += lines.height; }
|
|
290
|
+
const gradientId = `overview-band-${[...section.id].map(c => c.codePointAt(0).toString(16)).join('-')}`;
|
|
291
|
+
const gradient = tone.band && section.frame !== 'none' ? `<defs><linearGradient id="${gradientId}"><stop stop-color="${tone.band[0]}"/><stop offset="1" stop-color="${tone.band[1]}"/></linearGradient></defs>` : '';
|
|
292
|
+
const frame = section.frame === 'none' ? '' : `<rect x="${x}" y="${y}" width="${w}" height="${section.size.height}" rx="${OVERVIEW.sectionRadius}" fill="${gradient ? `url(#${gradientId})` : tone.fill}" stroke="${tone.stroke}" stroke-width="1.5"${section.frame === 'dashed' ? ' stroke-dasharray="5 4"' : ''}/>`;
|
|
293
|
+
return `<g data-overview-section-id="${escape(section.id)}">${gradient}${frame}${heading.lines.map((line, i) => svgText(x + p, y + p + OVERVIEW.sectionTitle + i * OVERVIEW.sectionLine, line, 'title', `fill:${colors.ink};font-size:${OVERVIEW.sectionTitle}px;font-weight:700`)).join('')}${body}</g>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function overviewGeometryErrors(graph, routes = new Map()) {
|
|
297
|
+
if (!isArchitectureOverview(graph)) return [];
|
|
298
|
+
const errors = [], sections = overviewSections(graph);
|
|
299
|
+
for (const section of sections) {
|
|
300
|
+
const required = sectionContentHeight(section, section.size.width) + sectionPadding(section);
|
|
301
|
+
if (section.size.height < required) errors.push(`section ${section.id} text is clipped`);
|
|
302
|
+
for (const item of Array.isArray(section.items) ? section.items : []) {
|
|
303
|
+
const child = item.nodeId ? graph.nodes.find(node => node.id === item.nodeId) : item.groupId ? graph.groups.find(group => group.id === item.groupId) : item;
|
|
304
|
+
if (child && (child.position.x < section.position.x + sectionPadding(section) || child.position.y < section.position.y + sectionContentHeight(section, section.size.width) + 24 || child.position.x + child.size.width > section.position.x + section.size.width - sectionPadding(section) + .01 || child.position.y + child.size.height > section.position.y + section.size.height - sectionPadding(section) + .01)) errors.push(`section ${section.id} does not contain ${child.id}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const itemBox = item => ({ ...item.position, ...item.size });
|
|
308
|
+
const overlaps = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
309
|
+
const siblings = items => {
|
|
310
|
+
const resolved = items.map(item => item.nodeId ? graph.nodes.find(node => node.id === item.nodeId) : item.groupId ? (graph.groups ?? []).find(group => group.id === item.groupId) : item).filter(Boolean);
|
|
311
|
+
for (let i = 0; i < resolved.length; i++) for (const other of resolved.slice(i + 1)) if (overlaps(itemBox(resolved[i]), itemBox(other))) errors.push(`overview siblings ${resolved[i].id} and ${other.id} overlap`);
|
|
312
|
+
};
|
|
313
|
+
siblings(graph.layout.sections);
|
|
314
|
+
for (const section of sections) if (section.items) siblings(section.items);
|
|
315
|
+
const crosses = (a, b, rect) => {
|
|
316
|
+
let low = 0, high = 1;
|
|
317
|
+
for (const [axis, dimension] of [['x', 'width'], ['y', 'height']]) {
|
|
318
|
+
const delta = b[axis] - a[axis];
|
|
319
|
+
if (!delta) { if (a[axis] <= rect[axis] || a[axis] >= rect[axis] + rect[dimension]) return false; continue; }
|
|
320
|
+
const t1 = (rect[axis] - a[axis]) / delta, t2 = (rect[axis] + rect[dimension] - a[axis]) / delta;
|
|
321
|
+
low = Math.max(low, Math.min(t1, t2)); high = Math.min(high, Math.max(t1, t2));
|
|
322
|
+
if (low >= high) return false;
|
|
323
|
+
}
|
|
324
|
+
return high > 0 && low < 1;
|
|
325
|
+
};
|
|
326
|
+
for (const section of sections) {
|
|
327
|
+
for (const obstacle of sectionTextBoxes(section)) for (const [id, route] of routes) {
|
|
328
|
+
if (route.points.slice(1).some((point, i) => crosses(route.points[i], point, obstacle))) errors.push(`overview route ${id} crosses section ${section.id} text`);
|
|
329
|
+
if (route.label && overlaps(route.labelBox, obstacle)) errors.push(`overview label ${id} overlaps section ${section.id} text`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return errors;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Kept as a compatible entry point for older callers. All types use the shared router.
|
|
336
|
+
export function routeOverview(graph) {
|
|
337
|
+
const result = routeOrthogonal(presentationGraph(graph), { passes: 3 });
|
|
338
|
+
graph.edges = mergePresentationEdges(graph, result.graph);
|
|
339
|
+
return graph;
|
|
340
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOverviewContent, overviewCardSvg } from '../architecture-overview.js';
|
|
1
2
|
import { genericCard } from './card.js';
|
|
2
3
|
import { cylinder, polygonAnchor, rectAnchor, roundedRectAnchor, rectangle, HEXAGON, polygon } from './drawing.js';
|
|
3
4
|
|
|
@@ -6,14 +7,15 @@ const SLANT = [[.08, 0], [1, 0], [.92, 1], [0, 1]];
|
|
|
6
7
|
|
|
7
8
|
export function architectureOutline(node, x, y) {
|
|
8
9
|
const { width: w, height: h } = node.size;
|
|
10
|
+
if (hasOverviewContent(node)) return rectangle(node, x, y, 16);
|
|
9
11
|
if (node.kind === 'external') return polygon(node, x, y, HEXAGON);
|
|
10
12
|
if (node.kind === 'security') return rectangle(node, x, y, 22);
|
|
11
13
|
if (node.kind === 'failure') return polygon(node, x, y, OCTAGON);
|
|
12
14
|
if (node.kind === 'config') return polygon(node, x, y, SLANT);
|
|
13
15
|
if (node.kind === 'business') return rectangle(node, x, y, Math.min(28, h / 2));
|
|
14
16
|
const base = rectangle(node, x, y, ['service', 'component'].includes(node.kind) ? 16 : 10);
|
|
15
|
-
// Notation stays in the card margins:
|
|
16
|
-
//
|
|
17
|
+
// Notation stays in the card margins: the icon plate starts 14px in, text 48px in, and the content block is centred
|
|
18
|
+
// vertically with 14px above and below.
|
|
17
19
|
if (node.kind === 'data') return [...base, ['path', { d: `M${x + 10} ${y}V${y + h}M${x + w - 10} ${y}V${y + h}`, fill: 'none' }]];
|
|
18
20
|
if (node.kind === 'database') return cylinder(node, x, y);
|
|
19
21
|
if (node.kind === 'framework') return [...base, ['path', { d: `M${x + 16} ${y + 6}H${x + w - 16}M${x + 16} ${y + h - 6}H${x + w - 16}`, fill: 'none', 'stroke-dasharray': '7 5' }]];
|
|
@@ -21,6 +23,7 @@ export function architectureOutline(node, x, y) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const anchor = (node, side, offset) => {
|
|
26
|
+
if (hasOverviewContent(node)) return roundedRectAnchor(node, side, offset, 16);
|
|
24
27
|
if (node.kind === 'external') return polygonAnchor(node, side, offset, HEXAGON);
|
|
25
28
|
if (node.kind === 'security') return roundedRectAnchor(node, side, offset, 22);
|
|
26
29
|
if (node.kind === 'failure') return polygonAnchor(node, side, offset, OCTAGON);
|
|
@@ -30,14 +33,14 @@ const anchor = (node, side, offset) => {
|
|
|
30
33
|
return rectAnchor(node, side, offset);
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
const contentInset = node => ['external', 'config', 'failure'].includes(node.kind) ? Math.min(28, node.size.width * .08) : 0;
|
|
36
|
+
const contentInset = node => ['external', 'config', 'failure'].includes(node.kind) ? Math.min(28, node.size.width * .08) : node.kind === 'database' ? 12 : 0;
|
|
34
37
|
|
|
35
38
|
export default {
|
|
36
39
|
dashedKinds: ['framework', 'optional'],
|
|
37
40
|
id: 'architecture', label: 'Architecture',
|
|
38
41
|
nodeKinds: ["external", "config", "framework", "security", "service", "business", "data", "failure", "system", "component", "database"],
|
|
39
42
|
groupKinds: ["runtime", "security", "ownership", "external"],
|
|
40
|
-
edgeKinds: ["request", "call", "data", "success", "failure", "framework", "optional", "depends"],
|
|
41
|
-
render: (node,
|
|
43
|
+
edgeKinds: ["request", "call", "data", "success", "failure", "framework", "optional", "depends", "aggregates", "inherits", "provides"],
|
|
44
|
+
render: (node, x, y, fill, stroke, palette, locale) => hasOverviewContent(node) ? overviewCardSvg(node, x, y, palette, fill, stroke) : genericCard(node, x, y, fill, stroke, palette, locale, architectureOutline, contentInset(node)), outline: architectureOutline, anchor,
|
|
42
45
|
cardLayout: true, contentInset,
|
|
43
46
|
};
|
|
@@ -1,21 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { cardTextLayout, text, coreNode, dataKinds,
|
|
1
|
+
import { CARD, cardTag } from '../text-layout.js';
|
|
2
|
+
import { cardTextLayout, text, coreNode, dataKinds, paint } from './drawing.js';
|
|
3
3
|
|
|
4
|
-
export function genericCard(node, x, y, fill, stroke, palette, locale, outline, contentInset = 0) {
|
|
5
|
-
|
|
4
|
+
export function genericCard(node, x, y, fill, stroke, palette, locale, outline, contentInset = 0, corner = 0) {
|
|
5
|
+
if (node.classicCard) return classicCard(node, x, y, fill, stroke, palette, locale, outline, contentInset);
|
|
6
|
+
const { title, subtitle, source, tag, contentHeight } = cardTextLayout({ ...node, size: { ...node.size, width: node.size.width - contentInset * 2 } }, locale, false, corner);
|
|
7
|
+
// Baselines sit 20/17/14px below the top of their 26/24/20px lines.
|
|
8
|
+
const left = x + contentInset, row = y + Math.max(0, (node.size.height - contentHeight) / 2) + CARD.pad, column = left + CARD.textX;
|
|
9
|
+
const subtitleTop = row + title.height + (subtitle.height ? 4 : 0), sourceTop = subtitleTop + subtitle.height + (source.height ? 6 : 0);
|
|
10
|
+
const titleText = title.lines.map((line, index) => text(column, row + 20 + index * title.lineHeight, line, 'title')).join('');
|
|
11
|
+
const subtitleText = subtitle.lines.map((line, index) => text(column, subtitleTop + 17 + index * subtitle.lineHeight, line, 'body')).join('');
|
|
12
|
+
const sourceText = source.lines.map((line, index) => text(column, sourceTop + 14 + index * source.lineHeight, line, 'meta')).join('');
|
|
13
|
+
const plate = { x: left + CARD.pad, y: row + (CARD.titleLine - CARD.plate) / 2 }, tagX = x + node.size.width - contentInset - CARD.pad - tag.width - corner;
|
|
14
|
+
return `<g filter="url(#node-shadow)">${frame(node, x, y, fill, stroke, outline)}${iconPlate(node, plate.x, plate.y, CARD.plate, palette)}<rect x="${tagX}" y="${row + 3}" width="${tag.width}" height="20" rx="6" fill="${palette.badge}"/>${text(tagX + tag.width / 2, row + 18, tag.text, 'stereotype', ' text-anchor="middle"')}${titleText}${subtitleText}${sourceText}</g>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const frame = (node, x, y, fill, stroke, outline) => paint(outline(node, x, y), { fill, stroke, 'stroke-width': node.appearance?.moduleColor || node.appearance?.ring ? 1.5 : 1 });
|
|
18
|
+
|
|
19
|
+
// The icon plate is the identity chip: solid module color with a white glyph. Without a module it stays a quiet plate
|
|
20
|
+
// whose glyph takes the role tone. Titles never change color.
|
|
21
|
+
function iconPlate(node, x, y, size, palette) {
|
|
22
|
+
const chip = node.appearance?.chip;
|
|
23
|
+
const tone = chip ? '#ffffff' : coreNode(node) ? palette.accent : dataKinds.has(node.kind) ? palette.data : node.kind === 'failure' ? palette.warn : palette.ink2;
|
|
24
|
+
const inset = (size - 15) / 2;
|
|
25
|
+
return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="7" fill="${chip ?? palette.badge}"/><svg x="${x + inset}" y="${y + inset}" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="${tone}" stroke-width="${chip ? 1.75 : 1.5}">${cardIcon(node)}</svg>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cardIcon = node => dataKinds.has(node.kind) ? '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>'
|
|
29
|
+
: ['external', 'actor'].includes(node.kind) ? '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 18h4"/>'
|
|
30
|
+
: ['security', 'config'].includes(node.kind) ? '<path d="m12 2 8 4v6c0 5-8 10-8 10S4 17 4 12V6Z"/><path d="m8 12 3 3 5-6"/>'
|
|
31
|
+
: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h8M8 17h4"/>';
|
|
32
|
+
|
|
33
|
+
// The card drawn before compact cards, kept for views whose stored geometry predates them (see compactCards).
|
|
34
|
+
function classicCard(node, x, y, fill, stroke, palette, locale, outline, contentInset) {
|
|
35
|
+
const { title, subtitle } = cardTextLayout({ ...node, size: { ...node.size, width: node.size.width - contentInset * 2 } }, locale, true);
|
|
6
36
|
const titleText = title.lines.map((line, index) => text(x + 15 + contentInset, y + 68 + index * title.lineHeight, line, 'title')).join('');
|
|
7
37
|
const subtitleText = subtitle.lines.map((line, index) => text(x + 15 + contentInset, y + 68 + title.height + index * subtitle.lineHeight, line, 'body')).join('');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// The icon plate is the identity chip: solid module color with a white glyph. Without a module it stays a quiet plate
|
|
11
|
-
// whose glyph takes the role tone. Titles never change color.
|
|
12
|
-
const chip = node.appearance?.chip, failure = node.kind === 'failure';
|
|
13
|
-
const tone = chip ? '#ffffff' : core ? palette.accent : dataKinds.has(node.kind) ? palette.data : failure ? palette.warn : palette.ink2;
|
|
14
|
-
const badge = chip ?? palette.badge;
|
|
15
|
-
const icon = dataKinds.has(node.kind) ? '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>'
|
|
16
|
-
: ['external', 'actor'].includes(node.kind) ? '<rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 18h4"/>'
|
|
17
|
-
: ['security', 'config'].includes(node.kind) ? '<path d="m12 2 8 4v6c0 5-8 10-8 10S4 17 4 12V6Z"/><path d="m8 12 3 3 5-6"/>'
|
|
18
|
-
: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h8M8 17h4"/>';
|
|
19
|
-
const frameWidth = node.appearance?.moduleColor || node.appearance?.ring ? 1.5 : 1;
|
|
20
|
-
return `<g filter="url(#node-shadow)">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': frameWidth })}<rect x="${x + 15 + contentInset}" y="${y + 13}" width="25" height="25" rx="7" fill="${badge}"/><svg x="${x + 20 + contentInset}" y="${y + 18}" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="${tone}" stroke-width="${chip ? 1.75 : 1.5}">${icon}</svg>${text(x + 47 + contentInset, y + 29, translate(locale, kindLabels[node.kind] ?? node.kind), 'stereotype')}${titleText}${subtitleText}</g>`;
|
|
38
|
+
return `<g filter="url(#node-shadow)">${frame(node, x, y, fill, stroke, outline)}${iconPlate(node, x + 15 + contentInset, y + 13, 25, palette)}${text(x + 47 + contentInset, y + 29, cardTag(node, locale).text, 'stereotype')}${titleText}${subtitleText}</g>`;
|
|
21
39
|
}
|
|
@@ -5,8 +5,8 @@ const CUBE = [[0, .1], [.06, 0], [1, 0], [1, .9], [.94, 1], [0, 1]];
|
|
|
5
5
|
|
|
6
6
|
export function deploymentOutline(node, x, y) {
|
|
7
7
|
const { width: w, height: h } = node.size;
|
|
8
|
-
// Notation stays clear of the card text: the stand sits under the
|
|
9
|
-
// and the
|
|
8
|
+
// Notation stays clear of the card text: the stand sits under the 14px bottom padding, and the stacked boxes, the folded
|
|
9
|
+
// corner and the cube's side face keep the top-right corner, which cardCorner leaves free of the kind tag.
|
|
10
10
|
if (node.kind === 'device') return [
|
|
11
11
|
['rect', { x: x + 16, y, width: w - 32, height: h, rx: 10 }],
|
|
12
12
|
['path', { d: `M${x + w / 2} ${y + h - 10}V${y + h - 5}M${x + w / 2 - 30} ${y + h - 5}H${x + w / 2 + 30}`, fill: 'none' }]
|
|
@@ -29,13 +29,15 @@ const anchor = (node, side, offset) => {
|
|
|
29
29
|
return rectAnchor(node, side, offset);
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// Right-hand room the kind tag leaves for the notation in the top-right corner.
|
|
33
|
+
const cardCorner = node => ({ container: 24, artifact: 18, node: 20 })[node.kind] ?? 0;
|
|
34
|
+
const contentInset = node => node.kind === 'external' ? Math.min(28, node.size.width * .08) : ['device', 'database'].includes(node.kind) ? 12 : 0;
|
|
33
35
|
|
|
34
36
|
export default {
|
|
35
37
|
id: 'deployment', label: 'Deployment',
|
|
36
38
|
nodeKinds: ["device", "node", "container", "artifact", "service", "database", "external"],
|
|
37
39
|
groupKinds: ["host", "network", "cluster", "namespace"],
|
|
38
40
|
edgeKinds: ["deploy", "network", "depends"],
|
|
39
|
-
render: (node, ...args) => genericCard(node, ...args, deploymentOutline, contentInset(node)), outline: deploymentOutline, anchor,
|
|
40
|
-
cardLayout: true, contentInset,
|
|
41
|
+
render: (node, ...args) => genericCard(node, ...args, deploymentOutline, contentInset(node), cardCorner(node)), outline: deploymentOutline, anchor,
|
|
42
|
+
cardLayout: true, contentInset, cardCorner,
|
|
41
43
|
};
|
|
@@ -8,7 +8,9 @@ export function groupHeadingSvg(group, x = 0, y = 0) {
|
|
|
8
8
|
|
|
9
9
|
export function groupFrameSvg(group, appearance, x = 0, y = 0) {
|
|
10
10
|
const radius = ['loop', 'par'].includes(group.kind) ? 5 : 14;
|
|
11
|
-
|
|
11
|
+
// A security boundary is a trust line, drawn dashed like Archify's security groups and UML trust boundaries.
|
|
12
|
+
const dash = group.kind === 'security' ? ' stroke-dasharray="6 4"' : '';
|
|
13
|
+
return `<rect class="boundary-frame" x="${x}" y="${y}" width="${group.size.width}" height="${group.size.height}" rx="${radius}" fill="${appearance.fill}" stroke="${appearance.stroke}" stroke-width="1"${dash}/>`;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export function escapeXml(value = '') {
|
|
@@ -64,7 +66,8 @@ export function paint(outline, attributes = {}) {
|
|
|
64
66
|
return outline.map(([tag, geometry]) => `<${tag}${Object.entries({ ...geometry, ...(Object.keys(attributes).length ? { class: 'node-surface' } : {}), ...attributes, ...(geometry.fill === undefined ? {} : { fill: geometry.fill }) }).map(([key, value]) => ` ${key}="${escapeXml(value)}"`).join('')}/>`).join('');
|
|
65
67
|
}
|
|
66
68
|
export const rectangle = (node, x, y, radius = 10, height = node.size.height) => [['rect', { x, y, width: node.size.width, height, rx: radius }]];
|
|
67
|
-
// Flat caps
|
|
69
|
+
// Flat caps (2–12px) stay above the card's 14px top padding and inside its 14px bottom padding; the walls stand 12px in,
|
|
70
|
+
// so database cards inset their content by 12px. The
|
|
68
71
|
// 2px top and bottom insets are where edges attach.
|
|
69
72
|
export const cylinder = (node, x, y) => {
|
|
70
73
|
const { width: w, height: h } = node.size, ry = 5, top = y + 2 + ry, bottom = y + h - 2 - ry;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOverviewContent, overviewCardLayout } from '../architecture-overview.js';
|
|
1
2
|
import architecture from './architecture.js';
|
|
2
3
|
import flowchart from './flowchart.js';
|
|
3
4
|
import sequence from './sequence.js';
|
|
@@ -7,6 +8,7 @@ import classDiagram from './class.js';
|
|
|
7
8
|
import state from './state.js';
|
|
8
9
|
import usecase from './usecase.js';
|
|
9
10
|
import dataflow from './dataflow.js';
|
|
11
|
+
import { cardTextLayout } from '../text-layout.js';
|
|
10
12
|
|
|
11
13
|
// Add a diagram module here; navigation, validation, drawing and exports use this same list.
|
|
12
14
|
export const DIAGRAMS = [architecture, flowchart, sequence, er, deployment, classDiagram, state, usecase, dataflow];
|
|
@@ -19,6 +21,14 @@ export const getDiagram = (type = 'architecture') => byId.get(type);
|
|
|
19
21
|
// Starting space budgets only; content bounds still determine the actual canvas and exports.
|
|
20
22
|
export const canvasBudgetFor = type => getDiagram(type).sequence ? null
|
|
21
23
|
: ['flowchart', 'state'].includes(type) ? { width: 1600, height: 2400 } : { width: 2400, height: 1600 };
|
|
24
|
+
// Card text inside the inset margins of the node's outline.
|
|
25
|
+
export const cardText = (node, type, locale, classic = false) => type === 'architecture' && hasOverviewContent(node) ? overviewCardLayout(node) : cardTextLayout({ ...node, size: { ...node.size, width: node.size.width - 2 * (getDiagram(type).contentInset?.(node) ?? 0) } }, locale, classic, getDiagram(type).cardCorner?.(node));
|
|
26
|
+
// A view uses compact cards when every card fits them; geometry stored before compact cards keeps the classic cards until it
|
|
27
|
+
// is laid out again, so preserved layouts stay valid and one view never mixes the two.
|
|
28
|
+
export const compactCards = graph => {
|
|
29
|
+
const type = diagramTypeOf(graph);
|
|
30
|
+
return !getDiagram(type).cardLayout || graph.nodes.every(node => !node.size || node.size.height >= cardText(node, type, graph.meta?.locale).minHeight);
|
|
31
|
+
};
|
|
22
32
|
export const hasArrow = (edge, type) => !(getDiagram(type).undirected ?? []).includes(edge.kind);
|
|
23
33
|
export const isDashed = (edge, type) => !(type === 'sequence' && edge.kind === 'sync') && (['framework', 'inference'].includes(edge.evidence) || (getDiagram(type).dashedKinds ?? []).includes(edge.kind));
|
|
24
34
|
|
|
@@ -1,21 +1,27 @@
|
|
|
1
|
+
import { layoutText } from '../text-layout.js';
|
|
1
2
|
import { translate } from '../i18n.js';
|
|
2
3
|
import { text, fit, TYPOGRAPHY, kindLabels, rectangle, actor, paint, escapeXml } from './drawing.js';
|
|
3
4
|
import { mix } from '../visual-style.js';
|
|
4
5
|
|
|
5
|
-
const outline = (node, x, y) => node.kind === 'actor' ? actor(node, x, y) : rectangle(node, x, y, 7,
|
|
6
|
-
export
|
|
6
|
+
const outline = (node, x, y) => node.kind === 'actor' ? actor(node, x, y) : rectangle(node, x, y, 7, sequenceHeaderHeight(node));
|
|
7
|
+
export function sequenceHeaderHeight(node) {
|
|
8
|
+
const width = (node.size?.width ?? 280) - 28;
|
|
9
|
+
const titles = layoutText(node.label, width, 20, 26).lines.length;
|
|
10
|
+
const bodies = node.subtitle ? layoutText(node.subtitle, width, 16, 22).lines.length : 0;
|
|
11
|
+
return (node.kind === 'actor' ? TYPOGRAPHY.sequenceActorHeader + (node.subtitle ? 22 : 0) : TYPOGRAPHY.sequenceHeader) + Math.max(0, titles - 1) * 26 + Math.max(0, bodies - 1) * 22;
|
|
12
|
+
}
|
|
7
13
|
|
|
8
14
|
function sequenceNode(node, x, y, fill, stroke, palette, locale) {
|
|
9
15
|
const cx = x + node.size.width / 2;
|
|
10
16
|
const actorNode = node.kind === 'actor';
|
|
11
17
|
const header = sequenceHeaderHeight(node);
|
|
12
18
|
const head = paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.5 });
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const subtitle = node.subtitle ? text(cx,
|
|
19
|
+
const titleLines = layoutText(node.label, node.size.width - 28, 20, 26).lines;
|
|
20
|
+
const titleY = y + (actorNode ? 98 : node.subtitle ? 44 : 53);
|
|
21
|
+
const label = (actorNode ? '' : text(cx, y + 20, translate(locale, kindLabels[node.kind] ?? node.kind), 'stereotype', ' text-anchor="middle"')) + titleLines.map((line, i) => text(cx, titleY + i * 26, line, 'participant-title', ' text-anchor="middle"')).join('');
|
|
22
|
+
const subtitle = node.subtitle ? layoutText(node.subtitle, node.size.width - 28, 16, 22).lines.map((line, i) => text(cx, titleY + (titleLines.length - 1) * 26 + (actorNode ? 28 : 22) + i * 22, line, 'body', ' text-anchor="middle"')).join('') : '';
|
|
17
23
|
const executions = (node.executionRects ?? []).map(item => `<rect class="sequence-execution" data-execution-id="${escapeXml(item.id)}" x="${x + item.x}" y="${y + item.y}" width="${item.width}" height="${item.height}" fill="${mix(item.color, palette.surface, .14)}" stroke="${item.color}" stroke-width="1.5"/>`).join('');
|
|
18
|
-
return `<g><g class="sequence-head"><g class="${actorNode ? 'actor-figure' : 'participant-head'}">${head}</g>${label}${subtitle}</g><path class="lifeline" d="M ${cx} ${y + header}V ${y + node.size.height}" stroke="${palette.edge}" stroke-width="1" stroke-dasharray="4 6"/>${executions}</g>`;
|
|
24
|
+
return `<g><title>${escapeXml([node.label, node.subtitle].filter(Boolean).join(" · "))}</title><g class="sequence-head"><g class="${actorNode ? 'actor-figure' : 'participant-head'}">${head}</g>${label}${subtitle}</g><path class="lifeline" d="M ${cx} ${y + header}V ${y + node.size.height}" stroke="${palette.edge}" stroke-width="1" stroke-dasharray="4 6"/>${executions}</g>`;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export default {
|