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.
Files changed (78) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +17 -0
  3. package/.claude-plugin/plugin.json +13 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/.cursor-plugin/plugin.json +9 -0
  6. package/.qoder-plugin/plugin.json +9 -0
  7. package/LICENSE +21 -0
  8. package/README.md +262 -0
  9. package/THIRD_PARTY_NOTICES.md +190 -0
  10. package/bin/qgraphflow.mjs +17 -0
  11. package/docs/clients.de.md +83 -0
  12. package/docs/clients.es.md +83 -0
  13. package/docs/clients.ja.md +83 -0
  14. package/docs/clients.md +83 -0
  15. package/docs/clients.pt.md +83 -0
  16. package/docs/clients.ru.md +83 -0
  17. package/docs/clients.zh-CN.md +83 -0
  18. package/docs/readme/README.de.md +262 -0
  19. package/docs/readme/README.es.md +262 -0
  20. package/docs/readme/README.ja.md +262 -0
  21. package/docs/readme/README.pt.md +262 -0
  22. package/docs/readme/README.ru.md +262 -0
  23. package/docs/readme/README.zh-CN.md +264 -0
  24. package/examples/order-flow.graph.json +94 -0
  25. package/package.json +61 -0
  26. package/skills/q-flow/SKILL.md +69 -0
  27. package/skills/q-flow/agents/openai.yaml +5 -0
  28. package/skills/q-flow/assets/layout-dist/ELK-LICENSE.md +264 -0
  29. package/skills/q-flow/assets/layout-dist/worker.mjs +24 -0
  30. package/skills/q-flow/assets/viewer/package.json +22 -0
  31. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +43 -0
  32. package/skills/q-flow/assets/viewer/src/diagrams/card.js +21 -0
  33. package/skills/q-flow/assets/viewer/src/diagrams/class.js +52 -0
  34. package/skills/q-flow/assets/viewer/src/diagrams/dataflow.js +19 -0
  35. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +41 -0
  36. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +174 -0
  37. package/skills/q-flow/assets/viewer/src/diagrams/er.js +34 -0
  38. package/skills/q-flow/assets/viewer/src/diagrams/flowchart.js +37 -0
  39. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +28 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +38 -0
  41. package/skills/q-flow/assets/viewer/src/diagrams/state.js +91 -0
  42. package/skills/q-flow/assets/viewer/src/diagrams/usecase.js +28 -0
  43. package/skills/q-flow/assets/viewer/src/edge-routing.js +596 -0
  44. package/skills/q-flow/assets/viewer/src/export-svg.js +90 -0
  45. package/skills/q-flow/assets/viewer/src/graph-validation.js +286 -0
  46. package/skills/q-flow/assets/viewer/src/i18n-messages.json +1314 -0
  47. package/skills/q-flow/assets/viewer/src/i18n.js +14 -0
  48. package/skills/q-flow/assets/viewer/src/layout-measure.js +55 -0
  49. package/skills/q-flow/assets/viewer/src/layout-quality.js +164 -0
  50. package/skills/q-flow/assets/viewer/src/layout-spacing.js +12 -0
  51. package/skills/q-flow/assets/viewer/src/node-svg.js +28 -0
  52. package/skills/q-flow/assets/viewer/src/radix-colors.js +47 -0
  53. package/skills/q-flow/assets/viewer/src/sequence-executions.js +140 -0
  54. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +208 -0
  55. package/skills/q-flow/assets/viewer/src/session-graph.js +43 -0
  56. package/skills/q-flow/assets/viewer/src/text-layout.js +126 -0
  57. package/skills/q-flow/assets/viewer/src/visual-style.js +158 -0
  58. package/skills/q-flow/assets/viewer-dist/index.html +291 -0
  59. package/skills/q-flow/references/acceptance.md +11 -0
  60. package/skills/q-flow/references/evidence-sources.md +38 -0
  61. package/skills/q-flow/references/graph-common.md +54 -0
  62. package/skills/q-flow/references/graph-schema.md +214 -0
  63. package/skills/q-flow/references/guided-intake.md +100 -0
  64. package/skills/q-flow/references/types/architecture.md +41 -0
  65. package/skills/q-flow/references/types/class.md +40 -0
  66. package/skills/q-flow/references/types/dataflow.md +41 -0
  67. package/skills/q-flow/references/types/deployment.md +37 -0
  68. package/skills/q-flow/references/types/er.md +36 -0
  69. package/skills/q-flow/references/types/flowchart.md +47 -0
  70. package/skills/q-flow/references/types/sequence.md +74 -0
  71. package/skills/q-flow/references/types/state.md +44 -0
  72. package/skills/q-flow/references/types/usecase.md +39 -0
  73. package/skills/q-flow/references/viewer-development.md +258 -0
  74. package/skills/q-flow/references/visual-contract.md +54 -0
  75. package/skills/q-flow/scripts/compile-layout.mjs +565 -0
  76. package/skills/q-flow/scripts/compile-sequence.mjs +112 -0
  77. package/skills/q-flow/scripts/generate-viewer.mjs +126 -0
  78. package/skills/q-flow/scripts/validate-graph.mjs +278 -0
@@ -0,0 +1,14 @@
1
+ import messages from './i18n-messages.json' with { type: 'json' };
2
+
3
+ // Interface strings are authored in English; every other language, including the default zh-CN, is a catalog.
4
+ // Graphs without meta.locale keep rendering in Chinese, so existing pages do not change. Authored text and code
5
+ // identifiers are never translated.
6
+ export const DEFAULT_LOCALE = 'zh-CN';
7
+ export const SUPPORTED_LOCALES = ['en', ...Object.keys(messages)];
8
+
9
+ export function translate(locale, message, values = {}) {
10
+ const effective = locale ?? DEFAULT_LOCALE;
11
+ const catalog = Object.hasOwn(messages, effective) ? messages[effective] : undefined;
12
+ const template = catalog && Object.hasOwn(catalog, message) ? catalog[message] : message;
13
+ return template.replace(/\{(\w+)\}/g, (token, key) => Object.hasOwn(values, key) ? String(values[key]) : token);
14
+ }
@@ -0,0 +1,55 @@
1
+ import { getDiagram } from './diagrams/registry.js';
2
+ import { sequenceHeaderHeight } from './diagrams/sequence.js';
3
+ import { cardTextLayout, layoutText } from './text-layout.js';
4
+ import { TYPOGRAPHY, kindLabels } from './visual-style.js';
5
+ import { translate } from './i18n.js';
6
+ import { STATE_ACTIONS, STATE_ACTION_INSET, stateActivities } from './diagrams/state.js';
7
+
8
+ // System fonts vary: reserve width beyond the estimate, then verify actual glyphs in the browser.
9
+ const singleLineWidth = (text, font) => Math.ceil(layoutText(String(text ?? '').replace(/\r?\n/g, ' '), Infinity, font).width * 1.15);
10
+ const rounded = size => Object.fromEntries(Object.entries(size).map(([key, value]) => [key, Math.ceil(value)]));
11
+
12
+ export function minimumNodeSize(node, type, locale = 'en') {
13
+ const diagram = getDiagram(type), title = singleLineWidth(node.label, TYPOGRAPHY.title), body = singleLineWidth(node.subtitle, TYPOGRAPHY.body);
14
+ if (type === 'state' && ['initial', 'final'].includes(node.kind)) {
15
+ if (!node.subtitle) return { width: 28, height: 28 };
16
+ const width = Math.max(title, body);
17
+ return rounded({ width: width + 50, height: Math.max(80, layoutText(node.label, width, TYPOGRAPHY.title, 29).height + 5 + layoutText(node.subtitle, width, TYPOGRAPHY.body, 23.2).height + 16) });
18
+ }
19
+ if (type === 'state' && STATE_ACTIONS.some(key => node.kind === 'state' && typeof node[key] === 'string' && node[key].trim())) {
20
+ // Title compartment (at least 72) above the action compartment, whose height follows the width it wraps at.
21
+ const width = Math.max(240, Math.min(480, Math.max(Math.max(title, body) + 60, ...STATE_ACTIONS.filter(key => node[key]).map(key => singleLineWidth(node[key], TYPOGRAPHY.small) + STATE_ACTION_INSET))));
22
+ const heading = layoutText(node.label, width - 24, TYPOGRAPHY.title, 29), subtitle = layoutText(node.subtitle, width - 24, TYPOGRAPHY.body, 23.2);
23
+ return rounded({ width, height: Math.max(72, heading.height + (subtitle.height ? 5 + subtitle.height : 0) + 16) + stateActivities({ ...node, size: { width, height: 0 } }).height });
24
+ }
25
+ if (diagram.sequence || node.kind === 'actor') return { width: Math.max(160, title + 28, body + 28), height: diagram.sequence ? sequenceHeaderHeight(node) + 48 : 104 + (node.subtitle ? 24 : 0) };
26
+ if (diagram.cardinalities) {
27
+ const fields = node.fields.map(field => {
28
+ const name = singleLineWidth(field.name, TYPOGRAPHY.body), type = singleLineWidth(field.type, TYPOGRAPHY.body);
29
+ return Math.max(name + type + 74, type / .55);
30
+ });
31
+ return rounded({ width: Math.max(240, title + 26, body + 60, ...fields), height: Math.max(TYPOGRAPHY.erHeader / .4, TYPOGRAPHY.erHeader + node.fields.length * TYPOGRAPHY.erRow) });
32
+ }
33
+ if (diagram.compartments) {
34
+ const attributes = node.attributes ?? [], methods = node.methods ?? [];
35
+ return rounded({ width: Math.max(240, title + 26, body + 26, ...[...attributes, ...methods].map(value => singleLineWidth(value, TYPOGRAPHY.body) + 26)),
36
+ height: TYPOGRAPHY.classHeader + (node.subtitle ? 24 : 0) + (attributes.length ? attributes.length * TYPOGRAPHY.classRow + 13 : 30) + (methods.length ? methods.length * TYPOGRAPHY.classRow + 13 : 30) });
37
+ }
38
+ const size = { width: Math.max(240, Math.min(480, Math.max(title, body) + 60)), height: 100 };
39
+ if (diagram.cardLayout) {
40
+ const inset = diagram.contentInset?.({ ...node, size }) ?? 0;
41
+ size.width = Math.max(size.width, singleLineWidth(translate(locale, kindLabels[node.kind] ?? node.kind), TYPOGRAPHY.small) + 70 + 2 * inset);
42
+ size.height = Math.max(100, cardTextLayout({ ...node, size: { ...size, width: size.width - 2 * (diagram.contentInset?.({ ...node, size }) ?? 0) } }).minHeight);
43
+ return rounded(size);
44
+ }
45
+ const textArea = diagram.textArea ?? (node => ({ width: node.size.width - 36, height: node.size.height - 20 }));
46
+ // All supported safety regions grow monotonically with height. Bounded expansion also handles diamond/ellipse insets.
47
+ for (let i = 0; i < 16; i++) {
48
+ const area = textArea({ ...node, size });
49
+ const titleText = layoutText(node.label, area.width, TYPOGRAPHY.title, 29), subtitle = layoutText(node.subtitle, area.width, TYPOGRAPHY.body, 23.2);
50
+ const required = titleText.height + (subtitle.height ? 5 + subtitle.height : 0);
51
+ if (area.height >= required) return rounded(size);
52
+ size.height += Math.ceil((required - area.height) * 2 + 2);
53
+ }
54
+ throw new Error(`Cannot measure full text for ${type} node ${node.id}`);
55
+ }
@@ -0,0 +1,164 @@
1
+ import { auditGraphLayout, boxDistance, occupiedBox, graphBounds, segmentCrossesBox } from './edge-routing.js';
2
+ import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
3
+ import { cardTextLayout, layoutText, groupHeadingLayout } from './text-layout.js';
4
+ import { LAYOUT_LIMITS } from './layout-spacing.js';
5
+ import { minimumNodeSize } from './layout-measure.js';
6
+ import { validateGraph } from './graph-validation.js';
7
+
8
+ const bounds = item => ({ ...item.position, ...item.size });
9
+ const contains = (a, b) => b.x >= a.x && b.y >= a.y && b.x + b.width <= a.x + a.width && b.y + b.height <= a.y + a.height;
10
+ const overlap = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
11
+ const segments = route => route.points.slice(1).map((end, i) => [route.points[i], end]);
12
+
13
+ export function qualityFailure(graph, phase, message, diagnostics = []) {
14
+ if (!diagnostics.length) diagnostics = (String(message).includes('\n- ') ? String(message).split('\n- ').slice(1) : [String(message)]).map(detail => {
15
+ const elements = ['nodes', 'edges', 'groups'].flatMap(key => (Array.isArray(graph?.[key]) ? graph[key] : []).filter((item, index) => item && typeof item === 'object' && (
16
+ detail.includes(`${key}[${index}]`) || new RegExp(`(^|[^\\w.-])${String(item.id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w.-])`).test(detail))));
17
+ return { ruleId: `${phase}.invalid`, severity: 'error', diagramType: diagramTypeOf(graph),
18
+ elementIds: elements.map(item => item.id), measured: detail, required: `Valid ${phase} content and complete notation`,
19
+ bounds: elements.filter(item => item.position && item.size).map(item => ({ ...item.position, ...item.size })),
20
+ remediation: phase === 'semantic' ? 'Correct the reported input field or reference before layout.' : 'Correct the identified content or geometry and retry without removing semantic facts.' };
21
+ });
22
+ return Object.assign(new Error(String(message)), { diagnostics, phases: {
23
+ semantic: { status: phase === 'semantic' ? 'failed' : 'passed' },
24
+ geometry: { status: phase === 'semantic' ? 'not-checked' : phase === 'geometry' ? 'failed' : 'passed' },
25
+ rendering: { status: phase === 'rendering' ? 'failed' : 'not-checked' }
26
+ } });
27
+ }
28
+
29
+ export function requireDiagramQuality(graph) {
30
+ const semantic = validateGraph(graph, { inputOnly: true });
31
+ const geometry = semantic.length ? [] : validateGraph(graph, { audit: false });
32
+ const audit = semantic.length || geometry.length ? null : auditLayoutQuality(graph);
33
+ const errors = [...semantic, ...geometry, ...(audit?.errors ?? [])];
34
+ const phases = { semantic: { status: semantic.length ? 'failed' : 'passed' }, geometry: { status: semantic.length ? 'not-checked' : errors.length ? 'failed' : 'passed' }, rendering: { status: 'not-checked' } };
35
+ if (errors.length) throw qualityFailure(graph, semantic.length ? 'semantic' : 'geometry', `Diagram quality failed:\n- ${errors.join('\n- ')}`, audit?.diagnostics ?? []);
36
+ return { ...phases, diagnostics: audit.diagnostics };
37
+ }
38
+
39
+ export function auditLayoutQuality(graph) {
40
+ const legacy = auditGraphLayout(graph), type = diagramTypeOf(graph), diagram = getDiagram(type), limits = LAYOUT_LIMITS;
41
+ const diagnostics = [...legacy.diagnostics, ...legacy.crossings];
42
+ const issue = (ruleId, ids, measured, required, boxes, remediation) => diagnostics.push({ ruleId, severity: 'error', diagramType: type, elementIds: ids, measured, required, bounds: boxes, remediation });
43
+ const nodeBoxes = new Map(graph.nodes.map(node => [node.id, occupiedBox(node, type)]));
44
+ const nodeById = new Map(graph.nodes.map(node => [node.id, node]));
45
+ if (!diagram.sequence) {
46
+ const ranked = graph.nodes.flatMap(first => graph.nodes.filter(second => first.layout?.rank !== undefined && second.layout?.rank !== undefined && first.layout.rank < second.layout.rank).map(second => [first, second]));
47
+ let primaryPath = graph.layout?.primaryPath ?? [];
48
+ if (type === 'flowchart' && !primaryPath.length && graph.edges.length === graph.nodes.length - 1) {
49
+ const next = new Map(graph.nodes.map(node => [node.id, graph.edges.filter(edge => edge.source === node.id)]));
50
+ const starts = graph.nodes.filter(node => !graph.edges.some(edge => edge.target === node.id));
51
+ if (starts.length === 1 && [...next.values()].every(edges => edges.length <= 1)) {
52
+ const path = [], seen = new Set();
53
+ for (let id = starts[0].id; id && !seen.has(id); id = next.get(id)[0]?.target) { path.push(id); seen.add(id); }
54
+ // Only a complete simple chain has an unambiguous main path; never guess among branches or cycles.
55
+ if (path.length === graph.nodes.length) primaryPath = path;
56
+ }
57
+ }
58
+ const primary = ['flowchart', 'state'].includes(type) ? primaryPath.slice(1).map((id, i) => [nodeById.get(primaryPath[i]), nodeById.get(id)]) : [];
59
+ const decisions = ['flowchart', 'state'].includes(type) ? graph.nodes.filter(node => ['decision', 'choice'].includes(node.kind)).map(node => {
60
+ const branches = graph.edges.filter(edge => edge.source === node.id && edge.target !== node.id);
61
+ return { node, branches, sides: new Set(branches.map(edge => legacy.routes.get(edge.id).sourceSide)) };
62
+ }).filter(item => item.branches.length >= 2) : [];
63
+ const vertical = !['er', 'deployment', 'dataflow', 'usecase'].includes(type);
64
+ const coordinate = vertical ? 'y' : 'x', dimension = vertical ? 'height' : 'width';
65
+ // A layout folded into top-down columns continues at the top of the next column: a successor that sits entirely to the
66
+ // right of its predecessor keeps the notation's reading direction; a path or hierarchy step must also start higher.
67
+ const continues = (earlier, later, higher) => later.position.x - earlier.position.x - earlier.size.width + .001 >= limits.nodeGap && (!higher || later.position.y < earlier.position.y);
68
+ for (const [first, second] of ranked) {
69
+ const distance = second.position[coordinate] - first.position[coordinate] - first.size[dimension];
70
+ if (distance + .001 < limits.nodeGap) issue('semantic.rank', [first.id, second.id], distance, limits.nodeGap, [nodeBoxes.get(first.id), nodeBoxes.get(second.id)], 'Keep declared ranks in separate layers along the reading direction.');
71
+ }
72
+ if (vertical && graph.nodes.length > 5 && Math.max(...graph.nodes.map(node => node.position.y)) < Math.min(...graph.nodes.map(node => node.position.y + node.size.height))) issue('semantic.single-row', graph.nodes.map(node => node.id), 1, 'multiple rows', [...nodeBoxes.values()], 'Arrange responsibility layers or wrap peer nodes; tiny y offsets do not create another row.');
73
+ if (type === 'class') for (const edge of graph.edges.filter(edge => ['inheritance', 'implementation'].includes(edge.kind))) {
74
+ const child = nodeById.get(edge.source), parent = nodeById.get(edge.target), distance = child.position.y - parent.position.y - parent.size.height;
75
+ if (distance + .001 < limits.nodeGap && !continues(parent, child, true)) issue('semantic.class-hierarchy', [edge.id, parent.id, child.id], distance, limits.nodeGap, [nodeBoxes.get(parent.id), nodeBoxes.get(child.id)], 'Place the parent class or interface above its child or implementation, or continue the hierarchy at the top of the next column.');
76
+ }
77
+ if (type === 'state') for (const symbol of graph.nodes.filter(node => ['initial', 'final'].includes(node.kind))) {
78
+ for (const state of graph.nodes.filter(node => !['initial', 'final'].includes(node.kind))) {
79
+ const distance = symbol.kind === 'initial' ? state.position.y - symbol.position.y - symbol.size.height : symbol.position.y - state.position.y - state.size.height;
80
+ const continued = symbol.kind === 'initial' ? continues(symbol, state, false) : continues(state, symbol, false);
81
+ if (distance + .001 < limits.nodeGap && !continued) issue('semantic.state-endpoint', [symbol.id, state.id], distance, limits.nodeGap, [nodeBoxes.get(symbol.id), nodeBoxes.get(state.id)], 'Keep initial symbols above the lifecycle and final symbols below it; a column fold continues the lifecycle at the top of the next column.');
82
+ }
83
+ }
84
+ for (const [source, target] of primary) {
85
+ const distance = target.position[coordinate] - source.position[coordinate] - source.size[dimension];
86
+ if (distance + .001 < limits.nodeGap && !continues(source, target, true)) issue('semantic.primary-path', [source.id, target.id], distance, limits.nodeGap, [nodeBoxes.get(source.id), nodeBoxes.get(target.id)], 'Keep the declared or unambiguous main path directed downwards; a column fold continues it at the top of the next column.');
87
+ }
88
+ for (const { node, branches, sides } of decisions) {
89
+ if (!['left', 'right'].every(side => sides.has(side))) issue('semantic.branch-sides', [node.id, ...branches.map(edge => edge.id)], [...sides], ['left', 'right'], [nodeBoxes.get(node.id)], 'Give alternatives separate left and right corridors while retaining their real targets and merges.');
90
+ }
91
+ }
92
+ for (let i = 0; i < graph.nodes.length; i++) {
93
+ const node = graph.nodes[i], rect = nodeBoxes.get(node.id), minimum = minimumNodeSize(node, type, graph.meta.locale);
94
+ if (diagram.cardLayout) {
95
+ const inset = diagram.contentInset?.(node) ?? 0, required = Math.max(100, cardTextLayout({ ...node, size: { ...node.size, width: node.size.width - inset * 2 } }).minHeight);
96
+ if (node.size.height < required) issue('text.node-height', [node.id], node.size.height, required, [rect], 'Increase height to keep full title and subtitle at 20/16px.');
97
+ } else if (diagram.textArea && node.kind !== 'actor' && !['initial', 'final'].includes(node.kind)) {
98
+ const area = diagram.textArea(node), title = layoutText(node.label, area.width, 20, 29), body = layoutText(node.subtitle, area.width, 16, 23.2), required = title.height + (body.height ? body.height + 5 : 0);
99
+ if (area.width <= 0 || area.height < required) issue('text.shape-safe-area', [node.id], area, { width: 1, height: required }, [rect], 'Enlarge the shape safety region to contain all text.');
100
+ } else if (node.size.width < minimum.width || node.size.height < minimum.height) issue('text.node-size', [node.id], node.size, minimum, [rect], 'Increase dimensions for complete headers, members and fields.');
101
+ for (const other of graph.nodes.slice(i + 1)) {
102
+ const otherBox = nodeBoxes.get(other.id), distance = boxDistance(rect, otherBox);
103
+ if (distance + .001 < limits.nodeGap) issue('spacing.nodes', [node.id, other.id], distance, limits.nodeGap, [rect, otherBox], 'Separate the visible node outlines.');
104
+ }
105
+ }
106
+ const labels = graph.edges.flatMap(edge => {
107
+ const route = legacy.routes.get(edge.id);
108
+ return [...(route.label ? [{ id: edge.id, box: route.labelBox }] : []), ...(route.endpointLabels ?? []).map(label => ({ id: edge.id, role: label.role, box: label.labelBox }))];
109
+ });
110
+ for (let i = 0; i < labels.length; i++) {
111
+ const label = labels[i];
112
+ for (const [id, rect] of nodeBoxes) {
113
+ const distance = boxDistance(label.box, rect);
114
+ if (distance + .001 < limits.labelGap) issue('spacing.label-node', [label.id, id], distance, limits.labelGap, [label.box, rect], 'Move the label away from the node.');
115
+ }
116
+ for (const other of labels.slice(i + 1)) {
117
+ const distance = boxDistance(label.box, other.box);
118
+ if (distance + .001 < limits.labelGap) issue('spacing.labels', [label.id, other.id], distance, limits.labelGap, [label.box, other.box], 'Separate complete label bounds.');
119
+ }
120
+ const margin = limits.labelEdgeGap;
121
+ for (const edge of graph.edges) if (edge.id !== label.id) {
122
+ const expanded = { x: label.box.x - margin, y: label.box.y - margin, width: label.box.width + 2 * margin, height: label.box.height + 2 * margin };
123
+ if (segments(legacy.routes.get(edge.id)).some(([a, b]) => segmentCrossesBox(a, b, expanded))) issue('spacing.label-edge', [label.id, edge.id], '<6', margin, [label.box], 'Reserve a clear label area away from other relations.');
124
+ }
125
+ }
126
+ for (let i = 0; i < graph.edges.length; i++) {
127
+ const edge = graph.edges[i], route = legacy.routes.get(edge.id), parts = segments(route);
128
+ if (!diagram.sequence) for (const [point, neighbor, hint] of [[route.points[0], route.points[1], edge.route?.via?.[0]], [route.points.at(-1), route.points.at(-2), edge.route?.via?.at(-1)]]) {
129
+ const distance = Math.hypot(point.x - neighbor.x, point.y - neighbor.y), required = diagram.endpointStub ?? limits.endpoint;
130
+ const length = hint && distance ? Math.min(distance, ((hint.x - point.x) * (neighbor.x - point.x) + (hint.y - point.y) * (neighbor.y - point.y)) / distance) : distance;
131
+ if (length + .001 < required) issue('route.endpoint-stub', [edge.id], length, required, [{ ...point, width: 0, height: 0 }], 'Reserve a straight outward segment for the endpoint symbol.');
132
+ }
133
+ for (const other of graph.edges.slice(i + 1)) {
134
+ const otherRoute = legacy.routes.get(other.id), found = [];
135
+ for (const [a, b] of parts) for (const [c, d] of segments(otherRoute)) {
136
+ const horizontal = a.y === b.y;
137
+ if (horizontal !== (c.y === d.y)) continue;
138
+ const axis = horizontal ? 'x' : 'y', across = horizontal ? 'y' : 'x';
139
+ const shared = Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis])) - Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
140
+ const separation = Math.abs(a[across] - c[across]);
141
+ if (shared <= .001 || separation + .001 >= limits.parallelGap) continue;
142
+ const terminals = (e, r) => [{ id: e.source, point: r.points[0] }, { id: e.target, point: r.points.at(-1) }];
143
+ const initialShared = separation < .001 && shared <= limits.endpoint && terminals(edge, route).some(left => terminals(other, otherRoute).some(right => left.id === right.id && left.point.x === right.point.x && left.point.y === right.point.y && (left.point === a || left.point === b) && (right.point === c || right.point === d)));
144
+ if (!initialShared) found.push({ x: Math.min(a.x, b.x, c.x, d.x), y: Math.min(a.y, b.y, c.y, d.y), width: Math.max(a.x, b.x, c.x, d.x) - Math.min(a.x, b.x, c.x, d.x), height: Math.max(a.y, b.y, c.y, d.y) - Math.min(a.y, b.y, c.y, d.y), separation, shared });
145
+ }
146
+ if (found.length) issue('route.parallel-channels', [edge.id, other.id], Math.min(...found.map(item => item.separation)), limits.parallelGap, found, `Separate parallel channels; only the initial ${limits.endpoint}px at one common port may overlap.`);
147
+ }
148
+ }
149
+ if (!diagram.sequence) {
150
+ const groups = graph.groups ?? [], byId = new Map(groups.map(group => [group.id, group]));
151
+ const belongs = (node, group) => { let id = node.groupId; const seen = new Set(); while (id && !seen.has(id)) { if (id === group.id) return true; seen.add(id); id = byId.get(id)?.parentId; } return false; };
152
+ for (let i = 0; i < groups.length; i++) {
153
+ const group = groups[i], rect = bounds(group), inset = limits.groupInset, top = groupHeadingLayout(group).height + limits.groupHeadingGap;
154
+ const inner = { x: rect.x + inset, y: rect.y + top, width: rect.width - 2 * inset, height: rect.height - top - inset };
155
+ const members = [...graph.nodes.filter(node => node.groupId === group.id), ...groups.filter(child => child.parentId === group.id)];
156
+ for (const member of members) if (!contains(inner, bounds(member))) issue('group.member-inset', [group.id, member.id], bounds(member), inner, [rect, bounds(member)], 'Expand the owning boundary to preserve heading and side clearance.');
157
+ for (const node of graph.nodes) if (!belongs(node, group) && overlap(rect, nodeBoxes.get(node.id))) issue('group.unowned-node', [group.id, node.id], node.groupId ?? null, 'outside', [rect, nodeBoxes.get(node.id)], 'Keep nodes outside boundaries they do not belong to.');
158
+ for (const other of groups.slice(i + 1)) if (group.parentId === other.parentId && boxDistance(rect, bounds(other)) + .001 < limits.groupGap) issue('group.sibling-gap', [group.id, other.id], boxDistance(rect, bounds(other)), limits.groupGap, [rect, bounds(other)], 'Separate sibling boundaries.');
159
+ }
160
+ }
161
+ // Existing shape/fragment/marker checks stay blocking; new rules carry structured locations above.
162
+ const errors = [...diagnostics.filter(item => item.severity === 'error').map(item => `${item.ruleId}: ${item.elementIds.join(', ')} (${JSON.stringify(item.measured)}; required ${JSON.stringify(item.required)})`)];
163
+ return { ...legacy, errors, diagnostics, bounds: graphBounds(graph, legacy.routes) };
164
+ }
@@ -0,0 +1,12 @@
1
+ // Graph units: safety limits are shared by generation, editing and export.
2
+ export const LAYOUT_LIMITS = Object.freeze({ nodeGap: 48, labelGap: 24, labelEdgeGap: 6, groupHeadingGap: 24, groupInset: 32, groupGap: 48, endpoint: 12, parallelGap: 24 });
3
+ export const LAYOUT_TARGETS = Object.freeze({ nodeGap: 64, layerGap: 80, edgeNodeGap: 24, labelWidth: 320, headingWidth: 360 });
4
+ // A finished diagram keeps its width/height ratio between 1/ASPECT_BAND (portrait) and ASPECT_BAND (landscape); the graph's
5
+ // own shape decides which side. Type budgets only express a preferred orientation inside this band.
6
+ export const ASPECT_BAND = 1.6;
7
+ // A shape within this factor of the band counts as acceptable: it triggers no fold, and among folds the fewest segments
8
+ // within it win. Without the slack a one-percent overshoot could trigger a fold that costs far more than it fixes.
9
+ export const ASPECT_SLACK = 1.1;
10
+ // How far a width/height ratio sits outside the band, as a factor ≥ 1; 1 means inside. Callers compare it to two decimals,
11
+ // so a shape within one percent of the band edge counts as inside.
12
+ export const ratioExcess = ratio => Math.max(ratio / ASPECT_BAND, 1 / (ratio * ASPECT_BAND), 1);
@@ -0,0 +1,28 @@
1
+ import { getDiagram } from './diagrams/registry.js';
2
+ import { escapeXml, coreNode, paint } from './diagrams/drawing.js';
3
+ import { nodeAppearance } from './visual-style.js';
4
+
5
+ export function renderNode(node, type, offsetX = 0, offsetY = 0, palette, locale, moduleColors) {
6
+ const definition = getDiagram(type);
7
+ if (!definition) throw new Error(`Unsupported diagram type: ${type}`);
8
+ const appearance = nodeAppearance(node, palette, moduleColors), { fill, stroke, ring } = appearance;
9
+ const x = node.position.x + offsetX, y = node.position.y + offsetY;
10
+ const content = definition.render({ ...node, appearance }, x, y, fill, stroke, palette, locale);
11
+ // A soft ring behind the frame marks the business center or an explicit failure without touching the text.
12
+ const halo = ring && !['actor', 'initial', 'final'].includes(node.kind) ? paint(definition.outline(node, x, y), { class: 'role-ring', fill: 'none', stroke: ring, 'stroke-width': 5 }) : '';
13
+ const description = [node.label, node.subtitle,
14
+ ...(node.fields ?? []).map(field => `${field.key ? field.key + ' ' : ''}${field.name}: ${field.type}${field.nullable === undefined ? '' : ` (nullable: ${field.nullable})`}`),
15
+ ...(node.attributes ?? []), ...(node.methods ?? [])].filter(Boolean).join('\n');
16
+ return `<g data-diagram-node-id="${escapeXml(node.id)}" class="node-drawing${coreNode(node) && !definition.compartments ? ' core-node' : ''}"><title>${escapeXml(description)}</title>${halo}${content}</g>`;
17
+ }
18
+
19
+ export function renderSelection(node, type) {
20
+ const definition = getDiagram(type);
21
+ return { markup: paint(definition.outline(node, 0, 0)), height: definition.selectionHeight?.(node) ?? node.size.height };
22
+ }
23
+
24
+ export function renderMiniMapNode(node, type, x, y, width, height, fill, stroke, strokeWidth) {
25
+ const definition = getDiagram(type);
26
+ const scaled = { ...node, position: { x, y }, size: { width, height } };
27
+ return paint(definition.outline(scaled, x, y), { fill, stroke, 'stroke-width': strokeWidth });
28
+ }
@@ -0,0 +1,47 @@
1
+ // Selected Radix Colors scales, copied verbatim from @radix-ui/colors@3.0.0: https://github.com/radix-ui/colors
2
+ // neutral = slate, accent = iris, data = cyan, warn = red, guard = amber (light and dark variants; index 0 is a placeholder, steps 1–12).
3
+ // MIT notice is retained in generated HTML and SVG.
4
+ export const RADIX = {
5
+ light: {
6
+ neutral: [null, "#fcfcfd", "#f9f9fb", "#f0f0f3", "#e8e8ec", "#e0e1e6", "#d9d9e0", "#cdced6", "#b9bbc6", "#8b8d98", "#80838d", "#60646c", "#1c2024"],
7
+ accent: [null, "#fdfdff", "#f8f8ff", "#f0f1fe", "#e6e7ff", "#dadcff", "#cbcdff", "#b8baf8", "#9b9ef0", "#5b5bd6", "#5151cd", "#5753c6", "#272962"],
8
+ data: [null, "#fafdfe", "#f2fafb", "#def7f9", "#caf1f6", "#b5e9f0", "#9ddde7", "#7dcedc", "#3db9cf", "#00a2c7", "#0797b9", "#107d98", "#0d3c48"],
9
+ warn: [null, "#fffcfc", "#fff7f7", "#feebec", "#ffdbdc", "#ffcdce", "#fdbdbe", "#f4a9aa", "#eb8e90", "#e5484d", "#dc3e42", "#ce2c31", "#641723"],
10
+ guard: [null, "#fefdfb", "#fefbe9", "#fff7c2", "#ffee9c", "#fbe577", "#f3d673", "#e9c162", "#e2a336", "#ffc53d", "#ffba18", "#ab6400", "#4f3422"],
11
+ },
12
+ dark: {
13
+ neutral: [null, "#111113", "#18191b", "#212225", "#272a2d", "#2e3135", "#363a3f", "#43484e", "#5a6169", "#696e77", "#777b84", "#b0b4ba", "#edeef0"],
14
+ accent: [null, "#13131e", "#171625", "#202248", "#262a65", "#303374", "#3d3e82", "#4a4a95", "#5958b1", "#5b5bd6", "#6e6ade", "#b1a9ff", "#e0dffe"],
15
+ data: [null, "#0b161a", "#101b20", "#082c36", "#003848", "#004558", "#045468", "#12677e", "#11809c", "#00a2c7", "#23afd0", "#4ccce6", "#b6ecf7"],
16
+ warn: [null, "#191111", "#201314", "#3b1219", "#500f1c", "#611623", "#72232d", "#8c333a", "#b54548", "#e5484d", "#ec5d5e", "#ff9592", "#ffd1d9"],
17
+ guard: [null, "#16120c", "#1d180f", "#302008", "#3f2700", "#4d3000", "#5c3d05", "#714f19", "#8f6424", "#ffc53d", "#ffd60a", "#ffca16", "#ffe7b3"],
18
+ },
19
+ };
20
+
21
+ // Eight identity scales for `module` colors, in the order call/return pairs cycle through them so neighbours differ most.
22
+ // visual-style.js reads step 9 for icon chips and washes, step 10 for card frames and relationship lines.
23
+ export const IDENTITY_SCALES = ['blue', 'orange', 'teal', 'crimson', 'violet', 'grass', 'plum', 'indigo'];
24
+ export const IDENTITY = {
25
+ light: {
26
+ blue: [null, "#fbfdff", "#f4faff", "#e6f4fe", "#d5efff", "#c2e5ff", "#acd8fc", "#8ec8f6", "#5eb1ef", "#0090ff", "#0588f0", "#0d74ce", "#113264"],
27
+ orange: [null, "#fefcfb", "#fff7ed", "#ffefd6", "#ffdfb5", "#ffd19a", "#ffc182", "#f5ae73", "#ec9455", "#f76b15", "#ef5f00", "#cc4e00", "#582d1d"],
28
+ teal: [null, "#fafefd", "#f3fbf9", "#e0f8f3", "#ccf3ea", "#b8eae0", "#a1ded2", "#83cdc1", "#53b9ab", "#12a594", "#0d9b8a", "#008573", "#0d3d38"],
29
+ crimson: [null, "#fffcfd", "#fef7f9", "#ffe9f0", "#fedce7", "#facedd", "#f3bed1", "#eaacc3", "#e093b2", "#e93d82", "#df3478", "#cb1d63", "#621639"],
30
+ violet: [null, "#fdfcfe", "#faf8ff", "#f4f0fe", "#ebe4ff", "#e1d9ff", "#d4cafe", "#c2b5f5", "#aa99ec", "#6e56cf", "#654dc4", "#6550b9", "#2f265f"],
31
+ grass: [null, "#fbfefb", "#f5fbf5", "#e9f6e9", "#daf1db", "#c9e8ca", "#b2ddb5", "#94ce9a", "#65ba74", "#46a758", "#3e9b4f", "#2a7e3b", "#203c25"],
32
+ plum: [null, "#fefcff", "#fdf7fd", "#fbebfb", "#f7def8", "#f2d1f3", "#e9c2ec", "#deade3", "#cf91d8", "#ab4aba", "#a144af", "#953ea3", "#53195d"],
33
+ indigo: [null, "#fdfdfe", "#f7f9ff", "#edf2fe", "#e1e9ff", "#d2deff", "#c1d0ff", "#abbdf9", "#8da4ef", "#3e63dd", "#3358d4", "#3a5bc7", "#1f2d5c"],
34
+ },
35
+ dark: {
36
+ blue: [null, "#0d1520", "#111927", "#0d2847", "#003362", "#004074", "#104d87", "#205d9e", "#2870bd", "#0090ff", "#3b9eff", "#70b8ff", "#c2e6ff"],
37
+ orange: [null, "#17120e", "#1e160f", "#331e0b", "#462100", "#562800", "#66350c", "#7e451d", "#a35829", "#f76b15", "#ff801f", "#ffa057", "#ffe0c2"],
38
+ teal: [null, "#0d1514", "#111c1b", "#0d2d2a", "#023b37", "#084843", "#145750", "#1c6961", "#207e73", "#12a594", "#0eb39e", "#0bd8b6", "#adf0dd"],
39
+ crimson: [null, "#191114", "#201318", "#381525", "#4d122f", "#5c1839", "#6d2545", "#873356", "#b0436e", "#e93d82", "#ee518a", "#ff92ad", "#fdd3e8"],
40
+ violet: [null, "#14121f", "#1b1525", "#291f43", "#33255b", "#3c2e69", "#473876", "#56468b", "#6958ad", "#6e56cf", "#7d66d9", "#baa7ff", "#e2ddfe"],
41
+ grass: [null, "#0e1511", "#141a15", "#1b2a1e", "#1d3a24", "#25482d", "#2d5736", "#366740", "#3e7949", "#46a758", "#53b365", "#71d083", "#c2f0c2"],
42
+ plum: [null, "#181118", "#201320", "#351a35", "#451d47", "#512454", "#5e3061", "#734079", "#92549c", "#ab4aba", "#b658c4", "#e796f3", "#f4d4f4"],
43
+ indigo: [null, "#11131f", "#141726", "#182449", "#1d2e62", "#253974", "#304384", "#3a4f97", "#435db1", "#3e63dd", "#5472e4", "#9eb1ff", "#d6e1ff"],
44
+ },
45
+ };
46
+
47
+ export const RADIX_COLORS_NOTICE = "MIT License\n\nCopyright (c) 2021-2022 Modulz\nCopyright (c) 2022-Present WorkOS\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n";
@@ -0,0 +1,140 @@
1
+ import { operandScopes } from './sequence-fragments.js';
2
+
3
+ // Pair numbers follow the calls' message order (C1 is the first call that gets a reply), not their id spelling.
4
+ export function sequencePairs(graph) {
5
+ if (graph.meta.diagramType !== 'sequence') return new Map();
6
+ const orderOf = new Map(graph.edges.map(edge => [edge.id, edge.order]));
7
+ const calls = [...new Set(graph.edges.map(edge => edge.replyTo).filter(Boolean))].sort((a, b) => (orderOf.get(a) ?? Infinity) - (orderOf.get(b) ?? Infinity) || (a < b ? -1 : a > b ? 1 : 0));
8
+ const indices = new Map(calls.map((id, index) => [id, index]));
9
+ return new Map(graph.edges.filter(edge => indices.has(edge.replyTo ?? edge.id)).map(edge => {
10
+ const callId = edge.replyTo ?? edge.id, index = indices.get(callId);
11
+ return [edge.id, { callId, index, label: `${edge.replyTo ? '↩ ' : ''}C${index + 1}` }];
12
+ }));
13
+ }
14
+
15
+ export function sequenceMessageLabel(graph, edge, pairs = sequencePairs(graph), scopes = operandScopes(graph)) {
16
+ const pair = pairs.get(edge.id);
17
+ const parallel = (graph.groups ?? []).some(group => group.kind === 'par' && (scopes.get(edge.id) ?? '').split('/').some(segment => segment.startsWith(encodeURIComponent(group.id) + ':')));
18
+ return pair ? `${pair.label} · ${edge.label ?? ''}` : parallel ? edge.label ?? '' : `${String(edge.order).padStart(2, '0')} · ${edge.label ?? ''}`;
19
+ }
20
+
21
+ // Hand-edited or legacy data may lack route.messageY, or the whole route. Such a message is stacked by order below the
22
+ // default headers so the page still opens; the layout check reports the missing coordinate and --layout auto writes it.
23
+ const FALLBACK_TOP = 140, FALLBACK_STEP = 54;
24
+ export const sequenceEndpointY = (edge, at) => (edge.route?.messageY ?? FALLBACK_TOP + edge.order * FALLBACK_STEP) + (at === 'receive' && edge.source === edge.target ? 30 : 0);
25
+
26
+ export function validateExecutions(graph, { inputOnly = false } = {}) {
27
+ const errors = [], edges = new Map(graph.edges.map(edge => [edge.id, edge]));
28
+ const sequence = graph.meta.diagramType === 'sequence';
29
+ if (!sequence) {
30
+ if (graph.executions !== undefined || graph.edges.some(edge => edge.replyTo !== undefined)) errors.push('executions and replyTo are only supported for sequence');
31
+ return errors;
32
+ }
33
+ if (!inputOnly) {
34
+ const ordered = [...graph.edges].sort((a, b) => a.order - b.order);
35
+ for (let i = 1; i < ordered.length; i++) if (sequenceEndpointY(ordered[i], 'send') <= sequenceEndpointY(ordered[i - 1], 'send')) errors.push(`edge ${ordered[i].id}.route.messageY must preserve message order after ${ordered[i - 1].id}`);
36
+ }
37
+ const scopes = operandScopes(graph), returned = new Set();
38
+ for (const edge of graph.edges) {
39
+ if (edge.replyTo === undefined) continue;
40
+ const call = edges.get(edge.replyTo), prefix = `edge ${edge.id}.replyTo ${String(edge.replyTo)}`;
41
+ if (edge.kind !== 'return' || typeof edge.replyTo !== 'string' || !call || !['sync', 'async'].includes(call.kind)) errors.push(`${prefix} must reference a sync or async call from a return`);
42
+ else {
43
+ if (call.order >= edge.order) errors.push(`${prefix} must precede its return`);
44
+ if (call.source !== edge.target || call.target !== edge.source) errors.push(`${prefix} endpoints must be reversed`);
45
+ if (scopes.get(call.id) !== scopes.get(edge.id)) errors.push(`${prefix} must stay in the same operand scope`);
46
+ }
47
+ if (returned.has(edge.replyTo)) errors.push(`${prefix} has a duplicate return`);
48
+ returned.add(edge.replyTo);
49
+ }
50
+ if (graph.executions === undefined) return errors;
51
+ if (!Array.isArray(graph.executions)) return [...errors, 'executions must be an array'];
52
+ const ids = new Map(), intervals = new Map(), anchors = new Map();
53
+ for (const execution of graph.executions) {
54
+ if (!execution || typeof execution !== 'object' || Array.isArray(execution)) { errors.push('execution must be an object'); continue; }
55
+ const prefix = `execution ${String(execution.id)}`;
56
+ if (typeof execution.id !== 'string' || !execution.id.trim()) errors.push(`${prefix} needs a non-empty id`);
57
+ if (ids.has(execution.id)) errors.push(`${prefix} duplicate id`);
58
+ ids.set(execution.id, execution);
59
+ if (!graph.nodes.some(node => node.id === execution.participantId)) errors.push(`${prefix} unknown participant ${String(execution.participantId)}`);
60
+ const ys = [];
61
+ for (const role of ['start', 'end']) {
62
+ const endpoint = execution[role], edge = edges.get(endpoint?.edgeId);
63
+ if (!edge || !['send', 'receive'].includes(endpoint?.at)) { errors.push(`${prefix}.${role} invalid endpoint ${String(endpoint?.edgeId)}`); continue; }
64
+ if (edge[endpoint.at === 'send' ? 'source' : 'target'] !== execution.participantId) errors.push(`${prefix}.${role} endpoint ${edge.id} does not belong to participant ${execution.participantId}`);
65
+ // Semantic ordering must not depend on coordinates or legacy message spacing.
66
+ ys.push(inputOnly ? edge.order * 2 + Number(endpoint.at === 'receive' && edge.source === edge.target) : sequenceEndpointY(edge, endpoint.at));
67
+ const key = `${edge.id}:${endpoint.at}`;
68
+ if (anchors.has(key)) errors.push(`${prefix}.${role} ambiguous endpoint ${key} with execution ${anchors.get(key)}`);
69
+ anchors.set(key, execution.id);
70
+ }
71
+ if (ys.length === 2) {
72
+ if (ys[0] >= ys[1]) errors.push(`${prefix} start must precede end`);
73
+ if (scopes.get(execution.start.edgeId) !== scopes.get(execution.end.edgeId)) errors.push(`${prefix} endpoints must stay in the same operand scope`);
74
+ intervals.set(execution.id, ys);
75
+ }
76
+ }
77
+ for (const execution of ids.values()) {
78
+ if (execution.parentId !== undefined) {
79
+ const parent = ids.get(execution.parentId);
80
+ if (!parent) errors.push(`execution ${execution.id} unknown parent ${String(execution.parentId)}`);
81
+ else {
82
+ if (parent.participantId !== execution.participantId) errors.push(`execution ${execution.id} parent ${parent.id} belongs to another participant`);
83
+ const a = intervals.get(parent.id), b = intervals.get(execution.id);
84
+ if (a && b && (b[0] < a[0] || b[1] > a[1])) errors.push(`execution ${execution.id} exceeds parent ${parent.id}`);
85
+ const parentScope = scopes.get(parent.start?.edgeId) ?? '', childScope = scopes.get(execution.start?.edgeId) ?? '';
86
+ if (parentScope && childScope !== parentScope && !childScope.startsWith(parentScope + '/')) errors.push(`execution ${execution.id} is outside parent ${parent.id} operand scope`);
87
+ }
88
+ }
89
+ const seen = new Set([execution.id]); let parent = ids.get(execution.parentId);
90
+ while (parent) {
91
+ if (seen.has(parent.id)) { errors.push(`execution ${execution.id} parent cycle at ${parent.id}`); break; }
92
+ seen.add(parent.id); parent = ids.get(parent.parentId);
93
+ }
94
+ }
95
+ const list = [...ids.values()];
96
+ const ancestor = (a, b) => { const seen = new Set(); let parent = ids.get(b.parentId); while (parent && !seen.has(parent.id)) { if (parent.id === a.id) return true; seen.add(parent.id); parent = ids.get(parent.parentId); } return false; };
97
+ for (let i = 0; i < list.length; i++) for (const other of list.slice(i + 1)) {
98
+ const item = list[i], a = intervals.get(item.id), b = intervals.get(other.id);
99
+ if (a && b && item.participantId === other.participantId && !ancestor(item, other) && !ancestor(other, item) && a[0] < b[1] && b[0] < a[1]) errors.push(`executions ${item.id} and ${other.id} overlap without nesting`);
100
+ }
101
+ return errors;
102
+ }
103
+
104
+ // Authoring rule for the CLI, not the Viewer: an answered sync call needs a bar on its callee from the call's receive
105
+ // to the reply's send. Legacy sequences have no replyTo, so they are never asked for one; async pairs need no bar. Two
106
+ // answered calls to one callee that interleave (the second opens inside the first and closes after it) cannot be drawn,
107
+ // since bars on one participant nest or stay apart, so neither is asked for one.
108
+ export function callsMissingExecutions(graph) {
109
+ if (graph.meta?.diagramType !== 'sequence') return [];
110
+ const edges = new Map(graph.edges.map(edge => [edge.id, edge])), bars = graph.executions ?? [];
111
+ const at = (anchor, edgeId, side) => anchor?.edgeId === edgeId && anchor.at === side;
112
+ const pairs = graph.edges.filter(reply => edges.get(reply.replyTo)?.kind === 'sync').map(reply => ({ call: edges.get(reply.replyTo), reply }));
113
+ const crosses = (a, b) => a.call.order < b.call.order && b.call.order < a.reply.order && a.reply.order < b.reply.order;
114
+ return pairs.filter(pair => !pairs.some(other => other.call.target === pair.call.target && (crosses(pair, other) || crosses(other, pair)))
115
+ && !bars.some(bar => bar.participantId === pair.call.target && at(bar.start, pair.call.id, 'receive') && at(bar.end, pair.reply.id, 'send')));
116
+ }
117
+
118
+ export const missingCallExecutions = graph => callsMissingExecutions(graph)
119
+ .map(({ call, reply }) => `edge ${call.id} sync call answered by ${reply.id} needs an execution on ${call.target} from ${call.id} receive to ${reply.id} send; --fix adds it`);
120
+
121
+ // Geometry is shared by routing, the participant SVG, exports and bounds.
122
+ export function sequenceExecutions(graph) {
123
+ if (graph.meta.diagramType !== 'sequence') return [];
124
+ const items = graph.executions ?? [], edges = new Map(graph.edges.map(edge => [edge.id, edge])), scopes = operandScopes(graph);
125
+ return items.map(item => {
126
+ let depth = 0, parent = items.find(other => other.id === item.parentId);
127
+ const seen = new Set([item.id]);
128
+ while (parent && !seen.has(parent.id)) { seen.add(parent.id); depth++; parent = items.find(other => other.id === parent.parentId); }
129
+ const node = graph.nodes.find(node => node.id === item.participantId);
130
+ const y = sequenceEndpointY(edges.get(item.start.edgeId), item.start.at);
131
+ return { ...item, depth, scope: scopes.get(item.start.edgeId) ?? '', x: node.position.x + node.size.width / 2 - 8 + depth * 8, y, width: 16, height: sequenceEndpointY(edges.get(item.end.edgeId), item.end.at) - y };
132
+ });
133
+ }
134
+
135
+ export function executionAt(executions, participantId, edge, at, y, scope = '') {
136
+ const candidates = executions.filter(item => item.participantId === participantId);
137
+ const bound = candidates.find(item => [item.start, item.end].some(anchor => anchor.edgeId === edge.id && anchor.at === at));
138
+ if (bound) return bound;
139
+ return candidates.filter(item => y >= item.y && y <= item.y + item.height && (!item.scope || scope === item.scope || scope.startsWith(item.scope + '/'))).sort((a, b) => b.depth - a.depth)[0];
140
+ }