qgraphflow 0.0.6
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.codex-plugin/plugin.json +26 -0
- package/.cursor-plugin/plugin.json +9 -0
- package/.qoder-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/THIRD_PARTY_NOTICES.md +190 -0
- package/bin/qgraphflow.mjs +17 -0
- package/docs/clients.de.md +83 -0
- package/docs/clients.es.md +83 -0
- package/docs/clients.ja.md +83 -0
- package/docs/clients.md +83 -0
- package/docs/clients.pt.md +83 -0
- package/docs/clients.ru.md +83 -0
- package/docs/clients.zh-CN.md +83 -0
- package/docs/readme/README.de.md +262 -0
- package/docs/readme/README.es.md +262 -0
- package/docs/readme/README.ja.md +262 -0
- package/docs/readme/README.pt.md +262 -0
- package/docs/readme/README.ru.md +262 -0
- package/docs/readme/README.zh-CN.md +264 -0
- package/examples/order-flow.graph.json +94 -0
- package/package.json +61 -0
- package/skills/q-flow/SKILL.md +69 -0
- package/skills/q-flow/agents/openai.yaml +5 -0
- package/skills/q-flow/assets/layout-dist/ELK-LICENSE.md +264 -0
- package/skills/q-flow/assets/layout-dist/worker.mjs +24 -0
- package/skills/q-flow/assets/viewer/package.json +22 -0
- package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +43 -0
- package/skills/q-flow/assets/viewer/src/diagrams/card.js +21 -0
- package/skills/q-flow/assets/viewer/src/diagrams/class.js +52 -0
- package/skills/q-flow/assets/viewer/src/diagrams/dataflow.js +19 -0
- package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +41 -0
- package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +174 -0
- package/skills/q-flow/assets/viewer/src/diagrams/er.js +34 -0
- package/skills/q-flow/assets/viewer/src/diagrams/flowchart.js +37 -0
- package/skills/q-flow/assets/viewer/src/diagrams/registry.js +28 -0
- package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +38 -0
- package/skills/q-flow/assets/viewer/src/diagrams/state.js +91 -0
- package/skills/q-flow/assets/viewer/src/diagrams/usecase.js +28 -0
- package/skills/q-flow/assets/viewer/src/edge-routing.js +596 -0
- package/skills/q-flow/assets/viewer/src/export-svg.js +90 -0
- package/skills/q-flow/assets/viewer/src/graph-validation.js +286 -0
- package/skills/q-flow/assets/viewer/src/i18n-messages.json +1314 -0
- package/skills/q-flow/assets/viewer/src/i18n.js +14 -0
- package/skills/q-flow/assets/viewer/src/layout-measure.js +55 -0
- package/skills/q-flow/assets/viewer/src/layout-quality.js +164 -0
- package/skills/q-flow/assets/viewer/src/layout-spacing.js +12 -0
- package/skills/q-flow/assets/viewer/src/node-svg.js +28 -0
- package/skills/q-flow/assets/viewer/src/radix-colors.js +47 -0
- package/skills/q-flow/assets/viewer/src/sequence-executions.js +140 -0
- package/skills/q-flow/assets/viewer/src/sequence-fragments.js +208 -0
- package/skills/q-flow/assets/viewer/src/session-graph.js +43 -0
- package/skills/q-flow/assets/viewer/src/text-layout.js +126 -0
- package/skills/q-flow/assets/viewer/src/visual-style.js +158 -0
- package/skills/q-flow/assets/viewer-dist/index.html +291 -0
- package/skills/q-flow/references/acceptance.md +11 -0
- package/skills/q-flow/references/evidence-sources.md +38 -0
- package/skills/q-flow/references/graph-common.md +54 -0
- package/skills/q-flow/references/graph-schema.md +214 -0
- package/skills/q-flow/references/guided-intake.md +100 -0
- package/skills/q-flow/references/types/architecture.md +41 -0
- package/skills/q-flow/references/types/class.md +40 -0
- package/skills/q-flow/references/types/dataflow.md +41 -0
- package/skills/q-flow/references/types/deployment.md +37 -0
- package/skills/q-flow/references/types/er.md +36 -0
- package/skills/q-flow/references/types/flowchart.md +47 -0
- package/skills/q-flow/references/types/sequence.md +74 -0
- package/skills/q-flow/references/types/state.md +44 -0
- package/skills/q-flow/references/types/usecase.md +39 -0
- package/skills/q-flow/references/viewer-development.md +258 -0
- package/skills/q-flow/references/visual-contract.md +54 -0
- package/skills/q-flow/scripts/compile-layout.mjs +565 -0
- package/skills/q-flow/scripts/compile-sequence.mjs +112 -0
- package/skills/q-flow/scripts/generate-viewer.mjs +126 -0
- package/skills/q-flow/scripts/validate-graph.mjs +278 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { layoutText } from './text-layout.js';
|
|
2
|
+
import { LAYOUT_TARGETS } from './layout-spacing.js';
|
|
3
|
+
import { translate } from './i18n.js';
|
|
4
|
+
import { text, escapeXml } from './diagrams/drawing.js';
|
|
5
|
+
|
|
6
|
+
export const intersects = (a, b, gap = 0) => a.x < b.x + b.width + gap && a.x + a.width > b.x - gap && a.y < b.y + b.height + gap && a.y + a.height > b.y - gap;
|
|
7
|
+
export const segmentBoxes = route => route.points.slice(1).map((end, i) => {
|
|
8
|
+
const start = route.points[i];
|
|
9
|
+
return { x: Math.min(start.x, end.x) - 6, y: Math.min(start.y, end.y) - 6, width: Math.abs(end.x - start.x) + 12, height: Math.abs(end.y - start.y) + 12 };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const operandId = (operand, index) => operand?.id ?? String(index);
|
|
13
|
+
export function operandScopes(graph) {
|
|
14
|
+
const groups = new Map((graph.groups ?? []).map(group => [group.id, group]));
|
|
15
|
+
const result = new Map();
|
|
16
|
+
const prefix = (group, seen = new Set()) => {
|
|
17
|
+
if (!group.parentId || seen.has(group.id)) return '';
|
|
18
|
+
seen.add(group.id);
|
|
19
|
+
const parent = groups.get(group.parentId);
|
|
20
|
+
return parent ? prefix(parent, seen) + `${encodeURIComponent(parent.id)}:${encodeURIComponent(group.parentOperandId)}/` : '';
|
|
21
|
+
};
|
|
22
|
+
for (const group of groups.values()) if (Array.isArray(group.operands)) for (const [i, operand] of group.operands.entries()) {
|
|
23
|
+
for (const id of Array.isArray(operand?.edgeIds) ? operand.edgeIds : []) result.set(id, prefix(group) + `${encodeURIComponent(group.id)}:${encodeURIComponent(operandId(operand, i))}`);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function operandEdges(group, operand, index, groups, seen = new Set()) {
|
|
29
|
+
if (seen.has(group.id)) return [];
|
|
30
|
+
const next = new Set(seen).add(group.id);
|
|
31
|
+
const children = groups.filter(child => child.parentId === group.id && child.parentOperandId === operandId(operand, index));
|
|
32
|
+
return [...(operand.edgeIds ?? []), ...children.flatMap(child => (child.operands ?? []).flatMap((item, i) => operandEdges(child, item, i, groups, next)))];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateOperands(graph) {
|
|
36
|
+
const errors = [], edges = new Map(graph.edges.map(edge => [edge.id, edge]));
|
|
37
|
+
const groups = graph.groups ?? [], used = new Map();
|
|
38
|
+
for (const group of groups) {
|
|
39
|
+
const prefix = `group ${group.id}.operands`;
|
|
40
|
+
if (graph.meta.diagramType !== 'sequence') {
|
|
41
|
+
if (group.operands !== undefined || group.parentOperandId !== undefined || group.loop !== undefined) errors.push(`${prefix} is only supported for sequence`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (group.parentId !== undefined || group.parentOperandId !== undefined) {
|
|
45
|
+
const parent = groups.find(item => item.id === group.parentId);
|
|
46
|
+
if (!parent || !Array.isArray(parent.operands) || !parent.operands.some((item, i) => operandId(item, i) === group.parentOperandId)) errors.push(`group ${group.id} unknown parent operand ${group.parentId}/${group.parentOperandId}`);
|
|
47
|
+
}
|
|
48
|
+
const seen = new Set([group.id]); let parent = groups.find(item => item.id === group.parentId);
|
|
49
|
+
while (parent) {
|
|
50
|
+
if (seen.has(parent.id)) { errors.push(`group ${group.id} parent cycle at ${parent.id}`); break; }
|
|
51
|
+
seen.add(parent.id); parent = groups.find(item => item.id === parent.parentId);
|
|
52
|
+
}
|
|
53
|
+
if (group.operands === undefined && group.kind !== 'par' && group.loop === undefined && group.parentId === undefined) continue;
|
|
54
|
+
const multiple = ['alt', 'par'].includes(group.kind);
|
|
55
|
+
if (!Array.isArray(group.operands) || (multiple ? group.operands.length < 2 : group.operands.length !== 1)) {
|
|
56
|
+
errors.push(`${prefix} needs ${multiple ? 'at least two branches' : 'exactly one branch'}`); continue;
|
|
57
|
+
}
|
|
58
|
+
if (group.kind === 'loop') {
|
|
59
|
+
if (!Number.isInteger(group.loop?.min) || group.loop.min < 0 || !(group.loop?.max === '*' || Number.isInteger(group.loop?.max) && group.loop.max >= group.loop.min)) errors.push(`group ${group.id}.loop needs a non-negative min and max >= min (or *)`);
|
|
60
|
+
} else if (group.loop !== undefined) errors.push(`group ${group.id}.loop is only supported for loop`);
|
|
61
|
+
const ids = new Set();
|
|
62
|
+
for (const [i, operand] of group.operands.entries()) {
|
|
63
|
+
const label = `${prefix}[${i}] (${String(operand?.id ?? i)})`;
|
|
64
|
+
if (!operand || typeof operand !== 'object' || Array.isArray(operand)) { errors.push(`${label} must be an object`); continue; }
|
|
65
|
+
if (operand.id !== undefined && (typeof operand.id !== 'string' || !operand.id.trim())) errors.push(`${label}.id must be a non-empty string`);
|
|
66
|
+
if (group.kind !== 'alt' && operand.id === undefined) errors.push(`${label}.id is required`);
|
|
67
|
+
const id = operandId(operand, i);
|
|
68
|
+
if (ids.has(id)) errors.push(`${label} duplicate operand id`);
|
|
69
|
+
ids.add(id);
|
|
70
|
+
const field = group.kind === 'par' ? 'label' : 'guard';
|
|
71
|
+
if (typeof operand[field] !== 'string' || !operand[field].trim()) errors.push(`${label}.${field} must be a non-empty string`);
|
|
72
|
+
if (group.kind === 'alt' && operand.guard?.trim?.() === 'else' && i !== group.operands.length - 1) errors.push(`${label} else must be last and unique`);
|
|
73
|
+
if (operand.body !== undefined && (typeof operand.body !== 'string' || !operand.body.trim())) errors.push(`${label}.body must be a non-empty string`);
|
|
74
|
+
const children = groups.filter(child => child.parentId === group.id && child.parentOperandId === id);
|
|
75
|
+
if (!Array.isArray(operand.edgeIds) || !operand.edgeIds.length && !operand.body?.trim?.() && !children.length) { errors.push(`${label}.edgeIds must be an array with messages, body or a child fragment`); continue; }
|
|
76
|
+
for (const edgeId of operand.edgeIds) {
|
|
77
|
+
if (typeof edgeId !== 'string' || !edges.has(edgeId)) errors.push(`${label} unknown edge ${String(edgeId)}`);
|
|
78
|
+
if (used.has(edgeId)) errors.push(`${label} duplicate edge ${edgeId}, already in ${used.get(edgeId)}`);
|
|
79
|
+
used.set(edgeId, `${group.id}/${id}`);
|
|
80
|
+
}
|
|
81
|
+
// Recursive ranges are checked after malformed operands have been rejected.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (errors.length) return errors;
|
|
85
|
+
for (const group of groups) {
|
|
86
|
+
let previous = -Infinity;
|
|
87
|
+
for (const [i, operand] of (group.operands ?? []).entries()) {
|
|
88
|
+
const orders = operandEdges(group, operand, i, groups).map(id => edges.get(id)?.order).filter(Number.isFinite);
|
|
89
|
+
if (orders.length && Math.min(...orders) <= previous) errors.push(`group ${group.id}.operands order ranges must be ordered and non-interleaving`);
|
|
90
|
+
if (orders.length) previous = Math.max(...orders);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function fragmentDepth(group, groups) {
|
|
97
|
+
let depth = 0, parent = groups.find(item => item.id === group.parentId);
|
|
98
|
+
const seen = new Set([group.id]);
|
|
99
|
+
while (parent && !seen.has(parent.id)) { seen.add(parent.id); depth++; parent = groups.find(item => item.id === parent.parentId); }
|
|
100
|
+
return depth;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Text masks inside a fragment borrow the innermost frame's surface, so they blend in both themes.
|
|
104
|
+
export function fragmentSurfaceAt(box, groups = [], appearances) {
|
|
105
|
+
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
|
106
|
+
const inside = groups.filter(group => group.position && group.size && cx >= group.position.x && cx <= group.position.x + group.size.width && cy >= group.position.y && cy <= group.position.y + group.size.height);
|
|
107
|
+
const innermost = inside.sort((a, b) => fragmentDepth(b, groups) - fragmentDepth(a, groups))[0];
|
|
108
|
+
return innermost ? appearances?.get(innermost.id)?.fill : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const fragmentHeadingLayout = group => layoutText(group.label, Math.min(LAYOUT_TARGETS.headingWidth, Math.max(1, (group.size?.width ?? Infinity) - 88)), 15.12, 22);
|
|
112
|
+
|
|
113
|
+
function fragmentHeading(group, executions) {
|
|
114
|
+
const frame = { ...group.position, ...group.size };
|
|
115
|
+
const layout = fragmentHeadingLayout(group);
|
|
116
|
+
const size = { width: Math.min(layout.width + 16, frame.width - 88), height: Math.max(24, layout.height + 8), lines: layout.lines };
|
|
117
|
+
return [frame.x + 16, ...executions.map(item => item.x + item.width + 12)].map(x => ({ x, y: frame.y + 6, ...size })).find(box => box.x >= frame.x + 16 && box.x + box.width <= frame.x + frame.width - 64 && !executions.some(item => intersects(box, item, 4)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// An alt without operands (older graphs) keeps its authored frame: one placeholder condition goes into free space beside
|
|
121
|
+
// the messages the frame encloses, and a warning says the real conditions are missing.
|
|
122
|
+
function legacyFragment(group, routes, locale, executions, heading) {
|
|
123
|
+
const frame = { ...group.position, ...group.size }, end = frame.y + frame.height;
|
|
124
|
+
const all = [...routes.entries()];
|
|
125
|
+
const inside = route => route.points.some(p => p.x >= frame.x && p.x <= frame.x + frame.width && p.y > frame.y && p.y < frame.y + frame.height);
|
|
126
|
+
const selected = all.filter(([, route]) => inside(route)).map(([, route]) => route);
|
|
127
|
+
const warnings = [`group ${group.id}: alt branch conditions are unspecified (operands missing)`];
|
|
128
|
+
const obstacles = [
|
|
129
|
+
...(heading ? [heading] : []),
|
|
130
|
+
{ x: frame.x + frame.width - 52, y: frame.y, width: 52, height: 36 },
|
|
131
|
+
...all.flatMap(([, r]) => [r.labelBox, ...segmentBoxes(r)]), ...executions
|
|
132
|
+
];
|
|
133
|
+
const layout = layoutText(translate(locale, 'Branch conditions unspecified'), Math.max(1, frame.width - 32), 14, 20);
|
|
134
|
+
const size = { width: layout.width + 8, height: layout.height + 4 };
|
|
135
|
+
const xs = [frame.x + 16, ...obstacles.map(box => box.x + box.width + 8), frame.x + frame.width - size.width - 16];
|
|
136
|
+
const top = Math.min(...selected.map(r => Math.min(r.labelBox.y, ...r.points.map(p => p.y))));
|
|
137
|
+
const ys = [frame.y + 6, top, ...selected.map(r => r.points[0].y - size.height - 6)];
|
|
138
|
+
const box = ys.flatMap(y => xs.map(x => ({ x, y, ...size }))).find(b => b.x >= frame.x + 8 && b.x + b.width <= frame.x + frame.width - 8 && b.y >= frame.y + 6 && b.y + b.height <= end - 6 && !obstacles.some(o => intersects(b, o, 2)));
|
|
139
|
+
if (!box) warnings.push(`layout: group ${group.id} has no room for guard 1; shorten the guard or adjust the frame/participant spacing`);
|
|
140
|
+
return { warnings, guards: [{ ...(box ?? { x: frame.x + 16, y: frame.y + 38, ...size }), lines: layout.lines, operandId: operandId(null, 0) }] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function sequenceFragment(group, routes, locale, groups = [], executions = []) {
|
|
144
|
+
const errors = [], warnings = [], guards = [], bodies = [], separators = [];
|
|
145
|
+
const frame = { ...group.position, ...group.size }, bottom = frame.y + frame.height;
|
|
146
|
+
// Frame relationships apply before either the operand-less or the operand layout can return.
|
|
147
|
+
const parent = groups.find(item => item.id === group.parentId);
|
|
148
|
+
if (parent && !(frame.x >= parent.position.x + 8 && frame.x + frame.width <= parent.position.x + parent.size.width - 8 && frame.y >= parent.position.y + 36 && bottom <= parent.position.y + parent.size.height - 8)) errors.push(`layout: group ${group.id} exceeds parent ${parent.id}`);
|
|
149
|
+
const ancestors = new Set(); let ancestor = parent;
|
|
150
|
+
while (ancestor && !ancestors.has(ancestor.id)) { ancestors.add(ancestor.id); ancestor = groups.find(item => item.id === ancestor.parentId); }
|
|
151
|
+
for (const other of groups) if (other.id !== group.id && !ancestors.has(other.id)) {
|
|
152
|
+
let item = other; const seen = new Set();
|
|
153
|
+
while (item?.parentId && !seen.has(item.id)) { seen.add(item.id); if (item.parentId === group.id) break; item = groups.find(g => g.id === item.parentId); }
|
|
154
|
+
if (item?.parentId === group.id) continue;
|
|
155
|
+
if (intersects(frame, { ...other.position, ...other.size })) errors.push(`layout: groups ${group.id} and ${other.id} cross without nesting`);
|
|
156
|
+
}
|
|
157
|
+
if (group.kind !== 'alt' && !group.operands) return { errors, warnings, guards, bodies, separators };
|
|
158
|
+
const heading = fragmentHeading(group, executions);
|
|
159
|
+
if (!heading) errors.push(`layout: group ${group.id} has no room for heading; adjust its frame`);
|
|
160
|
+
const children = groups.filter(child => child.parentId === group.id);
|
|
161
|
+
if (group.kind === 'alt' && group.operands === undefined && !group.parentId && !children.length) {
|
|
162
|
+
const fragment = legacyFragment(group, routes, locale, executions, heading);
|
|
163
|
+
return { errors, warnings: fragment.warnings, guards: fragment.guards, separators: [], heading };
|
|
164
|
+
}
|
|
165
|
+
if (!group.operands) return { errors, warnings, guards, bodies, separators };
|
|
166
|
+
const headingBottom = Math.max(frame.y + 36, heading ? heading.y + heading.height + 6 : 0);
|
|
167
|
+
const sections = group.operands.map((operand, i) => {
|
|
168
|
+
const childFrames = children.filter(child => child.parentOperandId === operandId(operand, i)).map(child => ({ ...child.position, ...child.size, id: child.id }));
|
|
169
|
+
const selected = operandEdges(group, operand, i, groups).map(id => routes.get(id)).filter(Boolean);
|
|
170
|
+
const boxes = [...childFrames, ...selected.flatMap(route => [route.labelBox, ...segmentBoxes(route)])];
|
|
171
|
+
return { operand, selected, childFrames, boxes, top: Math.min(...boxes.map(box => box.y)), bottom: Math.max(...boxes.map(box => box.y + box.height)) };
|
|
172
|
+
});
|
|
173
|
+
for (let i = 1; i < sections.length; i++) {
|
|
174
|
+
const previous = sections[i - 1], next = sections[i];
|
|
175
|
+
separators.push(Number.isFinite(previous.bottom) && Number.isFinite(next.top) ? (previous.bottom + next.top) / 2 : headingBottom + (bottom - headingBottom) * i / sections.length);
|
|
176
|
+
}
|
|
177
|
+
const members = new Set(group.operands.flatMap((operand, i) => operandEdges(group, operand, i, groups)));
|
|
178
|
+
for (const [id, route] of routes) if (route.points.some(p => p.x >= frame.x && p.x <= frame.x + frame.width && p.y > frame.y && p.y < bottom) && !members.has(id)) errors.push(`layout: group ${group.id} omits enclosed message ${id}`);
|
|
179
|
+
const allObstacles = [...routes.values()].flatMap(route => [route.labelBox, ...segmentBoxes(route)]);
|
|
180
|
+
allObstacles.push(...executions);
|
|
181
|
+
sections.forEach(({ operand, selected, childFrames, top }, i) => {
|
|
182
|
+
const begin = i ? separators[i - 1] : headingBottom, end = separators[i] ?? bottom;
|
|
183
|
+
for (const box of [...childFrames, ...selected.flatMap(route => [route.labelBox, ...segmentBoxes(route)])]) {
|
|
184
|
+
if (box.x < frame.x + 4 || box.x + box.width > frame.x + frame.width - 4 || box.y < begin + 2 || box.y + box.height > end - 2) errors.push(`layout: group ${group.id} cannot contain operand ${operandId(operand, i)}${box.id ? ` child ${box.id}` : ''}; enlarge or reposition its frame`);
|
|
185
|
+
}
|
|
186
|
+
const label = group.kind === 'par' ? operand.label : `${group.kind === 'loop' ? `${group.loop.min}..${group.loop.max} ` : ''}[${operand.guard}]`;
|
|
187
|
+
const obstacles = [...allObstacles, ...childFrames, ...guards, ...bodies];
|
|
188
|
+
const place = (value, kind, after) => {
|
|
189
|
+
const fontSize = kind === 'body' ? 16 : 14, lineHeight = kind === 'body' ? 24 : 20;
|
|
190
|
+
const layout = layoutText(value, Math.min(LAYOUT_TARGETS.labelWidth, Math.max(1, frame.width - 40)), fontSize, lineHeight);
|
|
191
|
+
const size = { width: layout.width + 8, height: layout.height + 4 };
|
|
192
|
+
const xs = [frame.x + 16, ...obstacles.map(box => box.x + box.width + 8), frame.x + frame.width - size.width - 16];
|
|
193
|
+
const ys = [after, ...obstacles.map(box => box.y + box.height + 8), top - size.height - 8].filter(Number.isFinite).sort((a, b) => a - b);
|
|
194
|
+
const box = ys.flatMap(y => xs.map(x => ({ x, y, ...size }))).find(box => box.x >= frame.x + 8 && box.x + box.width <= frame.x + frame.width - 8 && box.y >= after && box.y + box.height <= end - 6 && !obstacles.some(obstacle => intersects(box, obstacle, 2)));
|
|
195
|
+
if (!box) errors.push(`layout: group ${group.id} has no room for ${kind} ${operandId(operand, i)}; adjust frame/message spacing`);
|
|
196
|
+
else { box.lines = layout.lines; box.fontSize = fontSize; box.lineHeight = lineHeight; box.operandId = operandId(operand, i); obstacles.push(box); (kind === 'guard' ? guards : bodies).push(box); }
|
|
197
|
+
return box;
|
|
198
|
+
};
|
|
199
|
+
const guard = place(label, 'guard', begin + 6);
|
|
200
|
+
if (operand.body) place(operand.body, 'body', guard ? guard.y + guard.height + 8 : begin + 38);
|
|
201
|
+
});
|
|
202
|
+
return { errors, warnings, guards, bodies, separators, heading };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function renderFragment(fragment, group, offsetX = 0, offsetY = 0, stroke = 'currentColor', background = 'var(--canvas)') {
|
|
206
|
+
return fragment.separators.map(y => `<path class="operand-separator" d="M${group.position.x + offsetX} ${y + offsetY}h${group.size.width}" fill="none" stroke="${stroke}" stroke-dasharray="6 4"/>`).join('')
|
|
207
|
+
+ [...fragment.guards, ...(fragment.bodies ?? [])].map(box => `<g data-fragment-group-id="${escapeXml(group.id)}" data-operand-id="${escapeXml(box.operandId)}" class="${fragment.guards.includes(box) ? 'operand-guard' : 'operand-body'}"><rect x="${box.x + offsetX}" y="${box.y + offsetY}" width="${box.width}" height="${box.height}" rx="2" fill="${background}"/>${box.lines.map((line, i) => text(box.x + offsetX + 4, box.y + offsetY + (box.fontSize === 16 ? 18 : 16) + i * (box.lineHeight ?? 20), line, box.fontSize === 16 ? 'body' : 'group')).join('')}</g>`).join('');
|
|
208
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { diagramTypeOf } from './diagrams/registry.js';
|
|
2
|
+
|
|
3
|
+
// The generator embeds the graph as escaped JSON inside this script element; saving from the page rewrites the same
|
|
4
|
+
// element, so the saved page reopens with its edits and the generator and the page never disagree about the encoding.
|
|
5
|
+
const DATA_ELEMENT = /(<script\b[^>]*\bid="graph-data"[^>]*>)([\s\S]*?)(<\/script>)/;
|
|
6
|
+
export const safeJson = graph => JSON.stringify(graph).replaceAll('&', '\\u0026').replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
|
7
|
+
|
|
8
|
+
export function pageWithGraph(html, graph) {
|
|
9
|
+
if (!DATA_ELEMENT.test(html)) throw new Error('page has no graph-data element');
|
|
10
|
+
return html.replace(DATA_ELEMENT, (match, open, data, close) => `${open}${safeJson(graph)}${close}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function graphInputWithEdits(input, drafts, currentGraph) {
|
|
14
|
+
const edited = graph => diagramTypeOf(graph) === diagramTypeOf(currentGraph) ? currentGraph : drafts.get(diagramTypeOf(graph)) ?? graph;
|
|
15
|
+
return Array.isArray(input.diagrams) ? { ...input, diagrams: input.diagrams.map(edited) } : currentGraph;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function currentGraphFromFlow(graph, nodes, edges) {
|
|
19
|
+
const nodeState = new Map(nodes.filter(node => node.type === 'diagram').map(node => [node.id, node]));
|
|
20
|
+
const edgeState = new Map(edges.map(edge => [edge.id, edge]));
|
|
21
|
+
return {
|
|
22
|
+
...graph,
|
|
23
|
+
nodes: graph.nodes.map(node => {
|
|
24
|
+
const current = nodeState.get(node.id);
|
|
25
|
+
if (!current) return node;
|
|
26
|
+
const text = { label: current.data.label };
|
|
27
|
+
if (Object.hasOwn(current.data, 'subtitle')) text.subtitle = current.data.subtitle;
|
|
28
|
+
return { ...node, ...text, position: current.position };
|
|
29
|
+
}),
|
|
30
|
+
edges: graph.edges.map(edge => {
|
|
31
|
+
const current = edgeState.get(edge.id);
|
|
32
|
+
return current && Object.hasOwn(current.data, 'label') ? { ...edge, label: current.data.label } : edge;
|
|
33
|
+
})
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function constrainNodeChanges(changes, nodes, diagramType) {
|
|
38
|
+
if (diagramType !== 'sequence') return changes;
|
|
39
|
+
const positions = new Map(nodes.map(node => [node.id, node.position]));
|
|
40
|
+
return changes.map(change => change.type === 'position' && change.position && positions.has(change.id)
|
|
41
|
+
? { ...change, position: { ...change.position, y: positions.get(change.id).y } }
|
|
42
|
+
: change);
|
|
43
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { TYPOGRAPHY } from './visual-style.js';
|
|
2
|
+
import { LAYOUT_LIMITS, LAYOUT_TARGETS } from './layout-spacing.js';
|
|
3
|
+
|
|
4
|
+
// Conservative system-font widths keep server-rendered SVG and browser labels in the same bounds.
|
|
5
|
+
const textWidth = (value, fontSize) => [...value].reduce((width, character) => width + fontSize * (/[^\u0000-\u00ff]|[MWmw@%&]/.test(character) ? 1 : /[A-Z]/.test(character) ? .8 : 6.8 / 12), 0);
|
|
6
|
+
|
|
7
|
+
// A token (text between spaces) that fits on a line is never cut, so text without over-long tokens wraps exactly as it
|
|
8
|
+
// always did. A token longer than the line is cut where it reads best instead of at the first character that overflows:
|
|
9
|
+
// between CJK characters, then at an identifier's own separators (`_ . / - = ,` and camelCase humps), and only then
|
|
10
|
+
// anywhere. Closing punctuation never starts a line and opening punctuation never ends one; the neighbour travels along.
|
|
11
|
+
const CJK_CHARACTER = '\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\u3000-\\u303f\\uff00-\\uffef';
|
|
12
|
+
const CJK = new RegExp(`[${CJK_CHARACTER}]`, 'u');
|
|
13
|
+
const PIECES = new RegExp(`[${CJK_CHARACTER}]|[^${CJK_CHARACTER}]+`, 'gu');
|
|
14
|
+
const SEPARATORS = /(?<=[_./\\\-=,;:|&)>\]}])|(?<=[a-z0-9])(?=[A-Z])/u;
|
|
15
|
+
const NO_LINE_START = new Set([...',。、.;:!?)]}〕】》〉」』”’…']);
|
|
16
|
+
const NO_LINE_END = new Set([...'([{〔【《〈「『“‘']);
|
|
17
|
+
const canBreak = (before, after) => !NO_LINE_END.has([...before].at(-1)) && !NO_LINE_START.has([...after][0]);
|
|
18
|
+
|
|
19
|
+
function cutPieces(token, fontSize, limit) {
|
|
20
|
+
const pieces = [];
|
|
21
|
+
for (const run of token.match(PIECES)) {
|
|
22
|
+
if (CJK.test(run) || textWidth(run, fontSize) <= limit) pieces.push(run);
|
|
23
|
+
else for (const part of run.split(SEPARATORS)) pieces.push(...(textWidth(part, fontSize) <= limit ? [part] : part));
|
|
24
|
+
}
|
|
25
|
+
return pieces;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function layoutText(value, maxWidth, fontSize = TYPOGRAPHY.body, lineHeight = fontSize * 1.5) {
|
|
29
|
+
const content = String(value ?? '');
|
|
30
|
+
if (!content) return { lines: [], width: 0, height: 0, lineHeight };
|
|
31
|
+
const limit = Math.max(1, maxWidth);
|
|
32
|
+
const lines = [];
|
|
33
|
+
for (const paragraph of content.split(/\r?\n/)) {
|
|
34
|
+
let line = '';
|
|
35
|
+
let width = 0;
|
|
36
|
+
let cuts = []; // offsets in `line` where a token or piece starts: the places a kinsoku carry may cut
|
|
37
|
+
const wrap = () => {
|
|
38
|
+
lines.push(line);
|
|
39
|
+
line = '';
|
|
40
|
+
width = 0;
|
|
41
|
+
cuts = [];
|
|
42
|
+
};
|
|
43
|
+
for (const token of paragraph.match(/\s+|\S+/g) ?? []) {
|
|
44
|
+
const tokenWidth = textWidth(token, fontSize);
|
|
45
|
+
if (line && tokenWidth <= limit && width + tokenWidth > limit) wrap();
|
|
46
|
+
cuts.push(line.length);
|
|
47
|
+
if (tokenWidth <= limit || /^\s/.test(token)) {
|
|
48
|
+
for (const character of token) {
|
|
49
|
+
const characterWidth = textWidth(character, fontSize);
|
|
50
|
+
if (line && width + characterWidth > limit) wrap();
|
|
51
|
+
line += character;
|
|
52
|
+
width += characterWidth;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const piece of cutPieces(token, fontSize, limit)) {
|
|
57
|
+
const pieceWidth = textWidth(piece, fontSize);
|
|
58
|
+
if (line && width + pieceWidth > limit) {
|
|
59
|
+
if (canBreak(line, piece)) wrap();
|
|
60
|
+
else {
|
|
61
|
+
let k = cuts.length - 1;
|
|
62
|
+
while (k >= 0 && !(cuts[k] > 0 && canBreak(line.slice(0, cuts[k]), line.slice(cuts[k])))) k--;
|
|
63
|
+
if (k >= 0) {
|
|
64
|
+
const offset = cuts[k];
|
|
65
|
+
lines.push(line.slice(0, offset));
|
|
66
|
+
line = line.slice(offset);
|
|
67
|
+
width = textWidth(line, fontSize);
|
|
68
|
+
cuts = cuts.slice(k).map(cut => cut - offset);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
cuts.push(line.length);
|
|
73
|
+
line += piece;
|
|
74
|
+
width += pieceWidth;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
lines.push(line);
|
|
78
|
+
}
|
|
79
|
+
return { lines, width: Math.ceil(Math.max(...lines.map(line => textWidth(line, fontSize)))), height: lines.length * lineHeight, lineHeight };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function cardTextLayout(node) {
|
|
83
|
+
const width = node.size.width - 30;
|
|
84
|
+
const title = layoutText(node.label, width, TYPOGRAPHY.title, 26);
|
|
85
|
+
const subtitle = layoutText(node.subtitle ?? '', width, TYPOGRAPHY.body, 24);
|
|
86
|
+
return { title, subtitle, minHeight: 65 + title.height + subtitle.height };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function edgeLabelLayout(value, maxWidth = LAYOUT_TARGETS.labelWidth) {
|
|
90
|
+
const layout = layoutText(value, maxWidth);
|
|
91
|
+
return { ...layout, width: layout.lines.length ? Math.max(24, layout.width + 12) : 0, height: layout.lines.length ? layout.height + 6 : 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Each character of the joined label keeps the role of its part (the space between two parts keeps the role it follows);
|
|
95
|
+
// the wrapped lines are cut along those roles. Paragraph breaks inside a part are not drawn, so they are skipped between
|
|
96
|
+
// lines. Null when the lines do not spell the label.
|
|
97
|
+
export function labelRunsByLine(lines, parts) {
|
|
98
|
+
const label = parts.map(part => part.text).join(' ');
|
|
99
|
+
const roles = parts.flatMap((part, index) => [...(index ? [parts[index - 1].role] : []), ...Array.from({ length: part.text.length }, () => part.role)]);
|
|
100
|
+
let offset = 0;
|
|
101
|
+
const runsByLine = lines.map(line => {
|
|
102
|
+
while (label[offset] === '\r' || label[offset] === '\n') offset++;
|
|
103
|
+
if (!label.startsWith(line, offset)) return null;
|
|
104
|
+
const runs = [];
|
|
105
|
+
for (let i = 0; i < line.length; i++) {
|
|
106
|
+
const role = roles[offset + i];
|
|
107
|
+
if (runs.at(-1)?.role === role) runs.at(-1).text += line[i]; else runs.push({ text: line[i], role });
|
|
108
|
+
}
|
|
109
|
+
offset += line.length;
|
|
110
|
+
return runs;
|
|
111
|
+
});
|
|
112
|
+
while (label[offset] === '\r' || label[offset] === '\n') offset++;
|
|
113
|
+
return offset === label.length && runsByLine.every(Boolean) ? runsByLine : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function estimateLabelSize(value, maxWidth) {
|
|
117
|
+
const { width, height } = edgeLabelLayout(value, maxWidth);
|
|
118
|
+
return { width, height };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function groupHeadingLayout(group) {
|
|
122
|
+
const width = Math.min(LAYOUT_TARGETS.headingWidth, Math.max(1, (group.size?.width ?? Infinity) - 2 * LAYOUT_LIMITS.groupInset));
|
|
123
|
+
// Reserve the same letter spacing as the SVG group heading, plus a font margin.
|
|
124
|
+
const layout = layoutText(group.label, width, TYPOGRAPHY.small + 1.12, 22);
|
|
125
|
+
return { ...layout, height: 12 + layout.height };
|
|
126
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { IDENTITY, IDENTITY_SCALES, RADIX } from './radix-colors.js';
|
|
2
|
+
|
|
3
|
+
// Mix a color over a background: identity washes and headers stay tied to their chip color.
|
|
4
|
+
export function mix(color, background, amount) {
|
|
5
|
+
const channel = index => Math.round(parseInt(color.slice(index, index + 2), 16) * amount + parseInt(background.slice(index, index + 2), 16) * (1 - amount)).toString(16).padStart(2, '0');
|
|
6
|
+
return `#${channel(1)}${channel(3)}${channel(5)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// In-flight states of a state diagram walk this ramp, warm to cool, in the order the machine reaches them.
|
|
10
|
+
const FLIGHT_SCALES = ['orange', 'blue', 'teal', 'violet', 'plum', 'indigo'];
|
|
11
|
+
|
|
12
|
+
// Structure stays cool and neutral (Slate); identity is a saturated chip, a matching frame and a faint wash;
|
|
13
|
+
// roles speak through a soft ring (core, failure) and the icon glyph, never through the text.
|
|
14
|
+
export const PALETTES = Object.fromEntries(Object.entries(RADIX).map(([theme, { neutral: n, accent: t, data: b, warn: a, guard: g }]) => [theme, {
|
|
15
|
+
paper: n[1], surface: n[1], surface2: n[2],
|
|
16
|
+
ink: n[12], ink2: n[11], ink3: n[11], rule: n[6], ruleSoft: n[4],
|
|
17
|
+
accent: t[11], accentSoft: t[2], ringCore: t[5],
|
|
18
|
+
data: b[11], warn: a[11], ringWarn: a[5], guard: g[11],
|
|
19
|
+
badge: n[3], outline: theme === 'dark' ? n[10] : n[9],
|
|
20
|
+
// Dark cards lift one step above the canvas so a plain card still reads as a surface.
|
|
21
|
+
card: theme === 'dark' ? n[3] : n[1],
|
|
22
|
+
groupFill: n[2], groupFillNested: theme === 'dark' ? mix(n[2], n[3], .5) : n[1], groupLine: n[5],
|
|
23
|
+
moduleTones: IDENTITY_SCALES.map(name => { const scale = IDENTITY[theme][name]; return { name, chip: scale[9], accent: scale[10], wash: mix(scale[9], theme === 'dark' ? n[3] : n[1], theme === 'dark' ? .09 : .05), header: mix(scale[9], n[2], theme === 'dark' ? .16 : .1) }; }),
|
|
24
|
+
// Lifecycle tones of a state card: the goal is green, an ended state Slate, a failed one Red, and in-flight states take
|
|
25
|
+
// the ramp above. The body is step 3; the frame is step 11 on light cards and step 10 on dark ones, the first steps
|
|
26
|
+
// that hold 3:1 against that body in each theme.
|
|
27
|
+
stateTones: (() => {
|
|
28
|
+
const step = theme === 'dark' ? 10 : 11, scales = IDENTITY[theme], identity = name => ({ name, fill: scales[name][3], stroke: scales[name][step] });
|
|
29
|
+
return { goal: identity('grass'), ended: { name: 'slate', fill: n[3], stroke: n[step] }, failed: { name: 'red', fill: a[3], stroke: a[step] }, flight: FLIGHT_SCALES.map(identity) };
|
|
30
|
+
})(),
|
|
31
|
+
edge: theme === 'dark' ? n[10] : n[9],
|
|
32
|
+
mask: theme === 'dark' ? 'rgba(17,17,19,.75)' : 'rgba(252,252,253,.75)',
|
|
33
|
+
group: n[2]
|
|
34
|
+
}]));
|
|
35
|
+
|
|
36
|
+
export const warningKinds = new Set(['failure']);
|
|
37
|
+
export const dataKinds = new Set(['data', 'database', 'dataStore', 'entity']);
|
|
38
|
+
export const TYPOGRAPHY = { title: 20, body: 16, small: 14, edgeLineHeight: 24, sequenceHeader: 72, sequenceActorHeader: 108, erHeader: 72, erRow: 32, classHeader: 68, classRow: 28 };
|
|
39
|
+
export const isCore = node => node.kind === 'business' || (node.tags ?? []).some(tag => ['core', 'business'].includes(String(tag).trim().toLowerCase()));
|
|
40
|
+
|
|
41
|
+
// FNV-1a over the module name, bounded to the identity palette.
|
|
42
|
+
function colorSlot(value, count) {
|
|
43
|
+
let hash = 2166136261;
|
|
44
|
+
for (const character of value) hash = Math.imul(hash ^ character.codePointAt(0), 16777619);
|
|
45
|
+
return (hash >>> 0) % count;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hasTag = (node, name) => (node.tags ?? []).some(tag => String(tag).trim().toLowerCase() === name);
|
|
49
|
+
|
|
50
|
+
// A state diagram describes one component, so its module says nothing there: its states are colored by lifecycle role
|
|
51
|
+
// instead. The core state is the goal, a `failure`-tagged state is failed, a state with nowhere left to go (every way
|
|
52
|
+
// out ends the machine, or there is none) is ended, and the states in between are in flight and take the warm-to-cool
|
|
53
|
+
// ramp in the order the machine reaches them (distance from the initial state, then declaration order). Roles are
|
|
54
|
+
// derived from facts the graph already has; there is no color field. Without a core state the module colors stay.
|
|
55
|
+
export function stateToneRoles(graph) {
|
|
56
|
+
const states = graph.nodes.filter(node => node.kind === 'state');
|
|
57
|
+
if (graph.meta?.diagramType !== 'state' || !states.some(isCore)) return new Map();
|
|
58
|
+
const next = new Map(graph.nodes.map(node => [node.id, []]));
|
|
59
|
+
for (const edge of graph.edges) if (edge.kind === 'transition' && edge.source !== edge.target) next.get(edge.source)?.push(edge.target);
|
|
60
|
+
const ending = new Set(graph.nodes.filter(node => node.kind === 'final').map(node => node.id));
|
|
61
|
+
const distance = new Map(graph.nodes.filter(node => node.kind === 'initial').map(node => [node.id, 0]));
|
|
62
|
+
for (const id of distance.keys()) for (const target of next.get(id) ?? []) if (!distance.has(target)) distance.set(target, distance.get(id) + 1);
|
|
63
|
+
const role = node => hasTag(node, 'failure') ? 'failed' : isCore(node) ? 'goal' : next.get(node.id).every(id => ending.has(id)) ? 'ended' : 'flight';
|
|
64
|
+
const reach = node => distance.get(node.id) ?? Infinity;
|
|
65
|
+
const flight = states.filter(node => role(node) === 'flight').sort((a, b) => Math.sign(reach(a) - reach(b)) || 0);
|
|
66
|
+
return new Map(states.map(node => [node.id, role(node) === 'flight' ? { role: 'flight', index: flight.indexOf(node) } : { role: role(node) }]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The map also carries `stateTones` (state node id -> role): a collection has one state view, so the id is enough for
|
|
70
|
+
// every nodeAppearance caller, none of which knows its graph.
|
|
71
|
+
export function moduleColorMap(diagrams, palette) {
|
|
72
|
+
const modules = [...new Set(diagrams.flatMap(graph => [
|
|
73
|
+
...graph.nodes.map(node => node.module), ...graph.edges.map(edge => edge.module)
|
|
74
|
+
]).filter(Boolean))].sort();
|
|
75
|
+
// ponytail: bounded categorical slots can collide; module labels remain authoritative.
|
|
76
|
+
const colors = new Map(modules.map(module => [module, palette.moduleTones[colorSlot(module, palette.moduleTones.length)]]));
|
|
77
|
+
colors.stateTones = new Map(diagrams.flatMap(graph => [...stateToneRoles(graph)]));
|
|
78
|
+
return colors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A copy keeps what the map carries besides the module tones; a download snapshots the colors this way.
|
|
82
|
+
export function copyColors(colors) {
|
|
83
|
+
const copy = new Map(colors);
|
|
84
|
+
copy.stateTones = colors.stateTones;
|
|
85
|
+
copy.plainStates = colors.plainStates;
|
|
86
|
+
return copy;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The card-wash switch: module washes and state tones fall back to the plain card, frames and chips stay.
|
|
90
|
+
export function withoutWash(colors, palette) {
|
|
91
|
+
for (const [name, tone] of colors) colors.set(name, { ...tone, wash: palette.card, header: palette.surface2 });
|
|
92
|
+
colors.plainStates = true;
|
|
93
|
+
return colors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Boundaries are containers, not information: a hairline fence on a barely-there surface, nested one step apart.
|
|
97
|
+
export function groupAppearanceMap(groups, palette) {
|
|
98
|
+
const appearances = new Map();
|
|
99
|
+
const depth = group => { let level = 0, parent = groups.find(item => item.id === group.parentId); while (parent && level < groups.length) { level++; parent = groups.find(item => item.id === parent.parentId); } return level; };
|
|
100
|
+
for (const group of groups) appearances.set(group.id, { fill: depth(group) % 2 ? palette.groupFillNested : palette.groupFill, stroke: palette.groupLine });
|
|
101
|
+
return appearances;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const STATE_TONE_LABELS = { goal: 'Core state', ended: 'Ended state', failed: 'Failed state', flight: 'In-progress state' };
|
|
105
|
+
|
|
106
|
+
// The frame, chip and wash say whose a node is (module); a ring says it is the business center or an explicit failure.
|
|
107
|
+
// Text never follows either: titles stay ink. A failure keeps its red frame over any module. A state in a state
|
|
108
|
+
// diagram wears its lifecycle tone instead of the module's frame and wash; the ring and the module's lines stay.
|
|
109
|
+
export function nodeAppearance(node, palette, moduleColors) {
|
|
110
|
+
const tone = moduleColors?.get(node.module);
|
|
111
|
+
const stateRole = node.kind === 'state' ? moduleColors?.stateTones?.get(node.id) : undefined;
|
|
112
|
+
if (stateRole) {
|
|
113
|
+
const lifecycle = stateRole.role === 'flight' ? palette.stateTones.flight[stateRole.index % palette.stateTones.flight.length] : palette.stateTones[stateRole.role];
|
|
114
|
+
return { role: stateRole.role, label: STATE_TONE_LABELS[stateRole.role], fill: moduleColors.plainStates ? palette.card : lifecycle.fill, stroke: lifecycle.stroke, ring: isCore(node) ? palette.ringCore : undefined, toneIndex: stateRole.index, moduleColor: tone?.accent, chip: tone?.chip, header: tone?.header };
|
|
115
|
+
}
|
|
116
|
+
let appearance;
|
|
117
|
+
if (['initial', 'final'].includes(node.kind)) appearance = { role: node.kind, label: node.kind === 'initial' ? 'Initial state' : 'Final state', fill: node.kind === 'initial' ? palette.ink : palette.surface, stroke: palette.ink };
|
|
118
|
+
else if (warningKinds.has(node.kind)) appearance = { role: 'warning', label: 'Failure', fill: palette.card, stroke: palette.warn, ring: palette.ringWarn };
|
|
119
|
+
else if (isCore(node)) appearance = { role: 'core', label: 'Core component', fill: palette.card, stroke: palette.accent, ring: palette.ringCore };
|
|
120
|
+
else if (dataKinds.has(node.kind) || ['input', 'output'].includes(node.kind)) appearance = { role: 'data', label: 'Data / storage', fill: palette.card, stroke: palette.data };
|
|
121
|
+
else if (['start', 'end', 'usecase'].includes(node.kind)) appearance = { role: 'accent', label: node.kind === 'usecase' ? 'Use case' : 'Start / end', fill: palette.card, stroke: palette.accent };
|
|
122
|
+
else appearance = { role: 'neutral', label: 'Components / actors', fill: palette.card, stroke: palette.outline };
|
|
123
|
+
const framed = tone && !['initial', 'final', 'failure'].includes(node.kind);
|
|
124
|
+
return { ...appearance, fill: framed ? tone.wash : appearance.fill, stroke: framed ? tone.accent : appearance.stroke, moduleColor: tone?.accent, chip: tone?.chip, header: tone?.header };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Pairs walk the identity scales in order; the order itself keeps neighbouring pair numbers far apart in hue.
|
|
128
|
+
export const sequenceGroupColor = (pair, palette) => pair ? palette.moduleTones[pair.index % palette.moduleTones.length].accent : undefined;
|
|
129
|
+
|
|
130
|
+
export function edgeColor(edge, target, palette, moduleColors, source, pair) {
|
|
131
|
+
if (pair) return sequenceGroupColor(pair, palette);
|
|
132
|
+
if (edge.kind === 'failure') return palette.warn;
|
|
133
|
+
if (edge.kind === 'success') return palette.accent;
|
|
134
|
+
const tone = moduleColors?.get(edge.module ?? source?.module ?? target?.module);
|
|
135
|
+
if (tone) return tone.accent;
|
|
136
|
+
return dataKinds.has(target?.kind) ? palette.data : palette.edge;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function themeVariables(palette) {
|
|
140
|
+
const tokens = { bg: 'paper', panel: 'surface2', canvas: 'surface', ink: 'ink', muted: 'ink2', line: 'rule', accent: 'accent', 'accent-soft': 'accentSoft', warm: 'warn', guard: 'guard' };
|
|
141
|
+
const sizes = { 'font-body': 'body', 'font-small': 'small', 'edge-line-height': 'edgeLineHeight' };
|
|
142
|
+
return Object.fromEntries([...Object.entries(tokens).map(([name, key]) => [`--${name}`, palette[key]]), ...Object.entries(sizes).map(([name, key]) => [`--${name}`, `${TYPOGRAPHY[key]}px`])]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const kindLabels = {
|
|
146
|
+
external: 'External', config: 'Configuration', framework: 'Framework', security: 'Security', service: 'Service', business: 'Business',
|
|
147
|
+
data: 'Data', failure: 'Failure', system: 'System', component: 'Component', database: 'Database', start: 'Start', end: 'End',
|
|
148
|
+
process: 'Process', decision: 'Decision', input: 'Input', output: 'Output', subprocess: 'Subprocess', actor: 'Actor',
|
|
149
|
+
participant: 'Participant', entity: 'Entity', device: 'Device', node: 'Nodes', container: 'Container', artifact: 'Artifact',
|
|
150
|
+
class: 'Class', interface: 'Interface', abstract: 'Abstract class', state: 'State', initial: 'Initial', final: 'End', choice: 'Choice',
|
|
151
|
+
usecase: 'Use case', dataStore: 'Data store'
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function nodeMetrics(node) {
|
|
155
|
+
const erHeaderHeight = Math.min(TYPOGRAPHY.erHeader, node.size.height * .4);
|
|
156
|
+
const erRowHeight = Math.min(TYPOGRAPHY.erRow, (node.size.height - erHeaderHeight) / Math.max(1, node.fields?.length ?? 0));
|
|
157
|
+
return { erHeaderHeight, erRowHeight, erFontSize: Math.max(TYPOGRAPHY.small, Math.min(TYPOGRAPHY.body, erRowHeight - 6)), classHeaderHeight: TYPOGRAPHY.classHeader + (node.subtitle ? 24 : 0), classRowHeight: TYPOGRAPHY.classRow };
|
|
158
|
+
}
|