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,28 @@
|
|
|
1
|
+
import { text, fit, centeredTitle, actor, paint, actorAnchor, ellipseAnchor } from './drawing.js';
|
|
2
|
+
|
|
3
|
+
export const actorTop = node => Math.max(0, (node.size.height - 104 - (node.subtitle ? 24 : 0)) / 2);
|
|
4
|
+
const textArea = node => ({ width: node.size.width / Math.SQRT2 - 8, height: node.size.height / Math.SQRT2 - 8 });
|
|
5
|
+
const outline = (node, x, y) => node.kind === 'actor' ? actor(node, x, y + actorTop(node))
|
|
6
|
+
: [['ellipse', { cx: x + node.size.width / 2, cy: y + node.size.height / 2, rx: node.size.width / 2, ry: node.size.height / 2 }]];
|
|
7
|
+
const anchor = (node, side, offset) => node.kind === 'actor' ? actorAnchor(node, side, offset, actorTop(node)) : ellipseAnchor(node, side, offset);
|
|
8
|
+
function usecaseNode(node, x, y, fill, stroke, palette) {
|
|
9
|
+
const cx = x + node.size.width / 2;
|
|
10
|
+
if (node.kind === 'actor') return `<g><g class="actor-figure">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.5 })}</g>${text(cx, y + actorTop(node) + 98, fit(node.label, node.size.width - 12), 'shape-title', ` text-anchor="middle" style="fill:${palette.ink}"`)}${node.subtitle ? text(cx, y + actorTop(node) + 122, fit(node.subtitle, node.size.width - 12), 'body', ' text-anchor="middle"') : ''}</g>`;
|
|
11
|
+
const area = textArea(node);
|
|
12
|
+
return `<g class="shape-label">${paint(outline(node, x, y), { fill, stroke, 'stroke-width': 1.2 })}${centeredTitle(cx, y + node.size.height / 2, node.label, area.width, area.height, node.subtitle)}</g>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
dashedKinds: ['include', 'extend'],
|
|
17
|
+
id: 'usecase', label: 'Use cases',
|
|
18
|
+
nodeKinds: ["actor", "usecase"],
|
|
19
|
+
groupKinds: ["system"],
|
|
20
|
+
edgeKinds: ["association", "include", "extend"],
|
|
21
|
+
render: usecaseNode, outline, anchor, textArea, undirected: ['association'],
|
|
22
|
+
markers: { include: { end: 'arrow-open' }, extend: { end: 'arrow-open' } },
|
|
23
|
+
edgeLabel: edge => {
|
|
24
|
+
const stereotype = { include: '«include»', extend: '«extend»' }[edge.kind];
|
|
25
|
+
if (!stereotype) return edge.label ?? '';
|
|
26
|
+
return edge.label && edge.label !== edge.kind ? `${stereotype} · ${edge.label}` : stereotype;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { TYPOGRAPHY } from './visual-style.js';
|
|
2
|
+
import { cardTextLayout, estimateLabelSize, edgeLabelLayout, groupHeadingLayout, labelRunsByLine } from './text-layout.js';
|
|
3
|
+
import { LAYOUT_LIMITS, LAYOUT_TARGETS } from './layout-spacing.js';
|
|
4
|
+
import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
|
|
5
|
+
import { sequenceHeaderHeight } from './diagrams/sequence.js';
|
|
6
|
+
import { actorTop } from './diagrams/usecase.js';
|
|
7
|
+
import { stateSymbolX } from './diagrams/state.js';
|
|
8
|
+
import { sequencePairs, sequenceExecutions, sequenceEndpointY, executionAt, sequenceMessageLabel } from './sequence-executions.js';
|
|
9
|
+
import { sequenceFragment, fragmentHeadingLayout, intersects, segmentBoxes, operandScopes } from './sequence-fragments.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const center = node => ({
|
|
13
|
+
x: node.position.x + node.size.width / 2,
|
|
14
|
+
y: node.position.y + node.size.height / 2
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// A missing label falls back to interface words (a class relation's kind, a branch's yes / no) in the graph's locale.
|
|
18
|
+
export const visibleEdgeLabel = (edge, type, locale) => getDiagram(type)?.edgeLabel?.(edge, locale) ?? edge.label ?? '';
|
|
19
|
+
|
|
20
|
+
export function cardinalityMarks(cardinality, point, neighbor) {
|
|
21
|
+
const length = Math.hypot(neighbor.x - point.x, neighbor.y - point.y);
|
|
22
|
+
if (!length) return { path: '' };
|
|
23
|
+
const vx = (neighbor.x - point.x) / length;
|
|
24
|
+
const vy = (neighbor.y - point.y) / length;
|
|
25
|
+
const at = (forward, across = 0) => ({ x: point.x + vx * forward - vy * across, y: point.y + vy * forward + vx * across });
|
|
26
|
+
const bar = forward => [at(forward, -6), at(forward, 6)];
|
|
27
|
+
const value = String(cardinality).trim();
|
|
28
|
+
const many = /[*n]/i.test(value);
|
|
29
|
+
const optional = value.startsWith('0') || value === '*';
|
|
30
|
+
const segments = many ? [[at(0, -6), at(12)], [at(0, 6), at(12)], [point, at(12)]] : [bar(8)];
|
|
31
|
+
if (!optional) segments.push(bar(many ? 21 : 14));
|
|
32
|
+
const path = segments.map(([start, end]) => `M${start.x} ${start.y}L${end.x} ${end.y}`).join('');
|
|
33
|
+
const center = at(23);
|
|
34
|
+
const circle = optional ? { cx: center.x, cy: center.y, r: 4 } : undefined;
|
|
35
|
+
const points = segments.flat();
|
|
36
|
+
if (circle) points.push({ x: center.x - 4, y: center.y - 4 }, { x: center.x + 4, y: center.y + 4 });
|
|
37
|
+
const minX = Math.min(...points.map(item => item.x));
|
|
38
|
+
const minY = Math.min(...points.map(item => item.y));
|
|
39
|
+
const bounds = { x: minX, y: minY, width: Math.max(...points.map(item => item.x)) - minX, height: Math.max(...points.map(item => item.y)) - minY };
|
|
40
|
+
return { path, ...(circle ? { circle } : {}), bounds };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function graphBounds(graph, routes = createEdgeRoutes(graph)) {
|
|
44
|
+
const items = [...(graph.groups ?? []), ...graph.nodes, ...sequenceExecutions(graph).map(item => ({ position: { x: item.x, y: item.y }, size: { width: item.width, height: item.height } }))];
|
|
45
|
+
const points = [...routes.values()].flatMap(route => [...route.points, { x: route.labelBox.x, y: route.labelBox.y }, { x: route.labelBox.x + route.labelBox.width, y: route.labelBox.y + route.labelBox.height }]);
|
|
46
|
+
for (const route of routes.values()) for (const label of route.endpointLabels ?? []) points.push({ x: label.labelBox.x, y: label.labelBox.y }, { x: label.labelBox.x + label.labelBox.width, y: label.labelBox.y + label.labelBox.height });
|
|
47
|
+
if (getDiagram(graph.meta?.diagramType).cardinalities) for (const edge of graph.edges) {
|
|
48
|
+
const route = routes.get(edge.id);
|
|
49
|
+
for (const mark of [cardinalityMarks(edge.sourceCardinality, route.points[0], route.points[1]), cardinalityMarks(edge.targetCardinality, route.points.at(-1), route.points.at(-2))]) {
|
|
50
|
+
if (mark.bounds) points.push({ x: mark.bounds.x, y: mark.bounds.y }, { x: mark.bounds.x + mark.bounds.width, y: mark.bounds.y + mark.bounds.height });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const x = Math.min(...items.map(item => item.position.x), ...points.map(point => point.x));
|
|
54
|
+
const y = Math.min(...items.map(item => item.position.y), ...points.map(point => point.y));
|
|
55
|
+
const right = Math.max(...items.map(item => item.position.x + item.size.width), ...points.map(point => point.x));
|
|
56
|
+
const bottom = Math.max(...items.map(item => item.position.y + item.size.height), ...points.map(point => point.y));
|
|
57
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function anchor(node, side, offset = 0, type = 'architecture') {
|
|
61
|
+
const custom = getDiagram(type)?.anchor;
|
|
62
|
+
if (custom) return custom(node, side, offset);
|
|
63
|
+
const middle = center(node);
|
|
64
|
+
if (side === 'left') return { x: node.position.x, y: middle.y + offset };
|
|
65
|
+
if (side === 'right') return { x: node.position.x + node.size.width, y: middle.y + offset };
|
|
66
|
+
if (side === 'top') return { x: middle.x + offset, y: node.position.y };
|
|
67
|
+
return { x: middle.x + offset, y: node.position.y + node.size.height };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function outward(point, side, distance = LAYOUT_LIMITS.endpoint) {
|
|
71
|
+
if (side === 'left') return { x: point.x - distance, y: point.y };
|
|
72
|
+
if (side === 'right') return { x: point.x + distance, y: point.y };
|
|
73
|
+
if (side === 'top') return { x: point.x, y: point.y - distance };
|
|
74
|
+
return { x: point.x, y: point.y + distance };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function compact(points) {
|
|
78
|
+
const result = [];
|
|
79
|
+
for (const point of points) {
|
|
80
|
+
const previous = result.at(-1);
|
|
81
|
+
if (!previous || previous.x !== point.x || previous.y !== point.y) result.push(point);
|
|
82
|
+
}
|
|
83
|
+
for (let index = 1; index < result.length - 1;) {
|
|
84
|
+
const [before, current, after] = result.slice(index - 1, index + 2);
|
|
85
|
+
const vertical = before.x === current.x && current.x === after.x && (current.y - before.y) * (after.y - current.y) >= 0;
|
|
86
|
+
const horizontal = before.y === current.y && current.y === after.y && (current.x - before.x) * (after.x - current.x) >= 0;
|
|
87
|
+
if (vertical || horizontal) result.splice(index, 1);
|
|
88
|
+
else index += 1;
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendOrthogonal(points, point) {
|
|
94
|
+
const previous = points.at(-1);
|
|
95
|
+
if (previous.x !== point.x && previous.y !== point.y) points.push({ x: point.x, y: previous.y });
|
|
96
|
+
points.push(point);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function longestSegmentMidpoint(points) {
|
|
100
|
+
let longest = { length: -1, point: points[0] };
|
|
101
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
102
|
+
const before = points[index - 1];
|
|
103
|
+
const after = points[index];
|
|
104
|
+
const length = Math.abs(after.x - before.x) + Math.abs(after.y - before.y);
|
|
105
|
+
if (length > longest.length) longest = { length, point: { x: (before.x + after.x) / 2, y: (before.y + after.y) / 2 } };
|
|
106
|
+
}
|
|
107
|
+
return longest.point;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Every label sits on its own line, centred on the segment with the most clearance from both endpoints.
|
|
111
|
+
function bestLabelPoint(points, labelSize, source, target) {
|
|
112
|
+
let best = { clearance: -1, length: -1, point: longestSegmentMidpoint(points) };
|
|
113
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
114
|
+
const start = points[index - 1];
|
|
115
|
+
const end = points[index];
|
|
116
|
+
const point = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
117
|
+
const box = { x: point.x - labelSize.width / 2, y: point.y - labelSize.height / 2, ...labelSize };
|
|
118
|
+
const clearance = Math.min(boxDistance(box, { ...source.position, ...source.size }), boxDistance(box, { ...target.position, ...target.size }));
|
|
119
|
+
const length = Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
|
|
120
|
+
if (clearance > best.clearance || (clearance === best.clearance && length > best.length)) best = { clearance, length, point };
|
|
121
|
+
}
|
|
122
|
+
return best.point;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sidesFor(source, target) {
|
|
126
|
+
const sourceCenter = center(source);
|
|
127
|
+
const targetCenter = center(target);
|
|
128
|
+
const dx = targetCenter.x - sourceCenter.x;
|
|
129
|
+
const dy = targetCenter.y - sourceCenter.y;
|
|
130
|
+
if (Math.abs(dy) > Math.abs(dx)) return {
|
|
131
|
+
orientation: 'vertical',
|
|
132
|
+
sourceSide: dy >= 0 ? 'bottom' : 'top',
|
|
133
|
+
targetSide: dy >= 0 ? 'top' : 'bottom'
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
orientation: 'horizontal',
|
|
137
|
+
sourceSide: dx >= 0 ? 'right' : 'left',
|
|
138
|
+
targetSide: dx >= 0 ? 'left' : 'right'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function waypointEndpoint(node, point, fallbackSide, type) {
|
|
143
|
+
const { x, y } = node.position;
|
|
144
|
+
const { width, height } = node.size;
|
|
145
|
+
const dx = Math.max(x - point.x, 0, point.x - x - width);
|
|
146
|
+
const dy = Math.max(y - point.y, 0, point.y - y - height);
|
|
147
|
+
const side = dx === 0 && dy === 0 ? fallbackSide
|
|
148
|
+
: dx > dy ? (point.x < x ? 'left' : 'right') : (point.y < y ? 'top' : 'bottom');
|
|
149
|
+
const horizontal = side === 'left' || side === 'right';
|
|
150
|
+
const middle = center(node);
|
|
151
|
+
const limit = Math.max(0, (horizontal ? height : width) / 2 - LAYOUT_LIMITS.endpoint);
|
|
152
|
+
const offset = Math.max(-limit, Math.min(limit, horizontal ? point.y - middle.y : point.x - middle.x));
|
|
153
|
+
return { side, point: anchor(node, side, ['decision', 'choice'].includes(node.kind) ? 0 : offset, type) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function routePointsWithWaypoints(start, end, sourceSide, targetSide, waypoints, stub = LAYOUT_LIMITS.endpoint) {
|
|
157
|
+
const points = [start, outward(start, sourceSide, stub)];
|
|
158
|
+
// A shaped endpoint can sit inside its layout box. Turn at the real stub before joining its allocated channel.
|
|
159
|
+
if (['left', 'right'].includes(sourceSide) && waypoints.length && points[1].y !== waypoints[0].y) points.push({ x: points[1].x, y: waypoints[0].y });
|
|
160
|
+
for (const waypoint of waypoints) appendOrthogonal(points, waypoint);
|
|
161
|
+
appendOrthogonal(points, outward(end, targetSide, stub));
|
|
162
|
+
points.push(end);
|
|
163
|
+
return compact(points);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function routeBetween(source, target, sides, sourceOffset, targetOffset, stub = LAYOUT_LIMITS.endpoint, type = 'architecture') {
|
|
167
|
+
const start = anchor(source, sides.sourceSide, sourceOffset, type);
|
|
168
|
+
const end = anchor(target, sides.targetSide, targetOffset, type);
|
|
169
|
+
const sourceStub = outward(start, sides.sourceSide, stub);
|
|
170
|
+
const targetStub = outward(end, sides.targetSide, stub);
|
|
171
|
+
const channelOffset = sourceOffset || targetOffset;
|
|
172
|
+
if (sides.orientation === 'horizontal') {
|
|
173
|
+
const middle = (sourceStub.x + targetStub.x) / 2 + channelOffset;
|
|
174
|
+
return compact([start, sourceStub, { x: middle, y: sourceStub.y }, { x: middle, y: targetStub.y }, targetStub, end]);
|
|
175
|
+
}
|
|
176
|
+
const middle = (sourceStub.y + targetStub.y) / 2 + channelOffset;
|
|
177
|
+
return compact([start, sourceStub, { x: sourceStub.x, y: middle }, { x: targetStub.x, y: middle }, targetStub, end]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function pathFromPoints(points, offsetX = 0, offsetY = 0) {
|
|
181
|
+
if (!points.length) return '';
|
|
182
|
+
const shifted = points.map(point => ({ x: point.x + offsetX, y: point.y + offsetY }));
|
|
183
|
+
return shifted.slice(1).reduce((path, point, index) => {
|
|
184
|
+
const previous = shifted[index];
|
|
185
|
+
if (previous.y === point.y) return `${path} H ${point.x}`;
|
|
186
|
+
if (previous.x === point.x) return `${path} V ${point.y}`;
|
|
187
|
+
return `${path} L ${point.x} ${point.y}`;
|
|
188
|
+
}, `M ${shifted[0].x} ${shifted[0].y}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A self-transition leaves and re-enters one side as a single arc. Its points stay the laid-out polyline, so every audit
|
|
192
|
+
// sees the bounding box the arc stays inside; the arc only smooths it, reaching as far out as that polyline does. Ends on
|
|
193
|
+
// two sides (a moved waypoint) or on one point (a choice) keep the polyline: no single arc on one side can draw them.
|
|
194
|
+
function arcPath(route, offsetX, offsetY) {
|
|
195
|
+
if (route.sourceSide !== route.targetSide) return null;
|
|
196
|
+
const [start, ...rest] = route.points.map(point => ({ x: point.x + offsetX, y: point.y + offsetY })), end = rest.at(-1);
|
|
197
|
+
const [nx, ny] = { right: [1, 0], left: [-1, 0], top: [0, -1], bottom: [0, 1] }[route.sourceSide] ?? [1, 0], [tx, ty] = [-ny, nx];
|
|
198
|
+
const reach = Math.max(...rest.map(point => (point.x - start.x) * nx + (point.y - start.y) * ny)) * 4 / 3;
|
|
199
|
+
const span = (end.x - start.x) * tx + (end.y - start.y) * ty, lift = Math.abs(span) * .35, direction = Math.sign(span);
|
|
200
|
+
if (!(reach > 0) || Math.abs(span) < 1) return null;
|
|
201
|
+
const c1 = { x: start.x + nx * reach - tx * direction * lift, y: start.y + ny * reach - ty * direction * lift };
|
|
202
|
+
const c2 = { x: end.x + nx * reach + tx * direction * lift, y: end.y + ny * reach + ty * direction * lift };
|
|
203
|
+
return `M ${start.x} ${start.y} C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const pathFromRoute = (route, offsetX = 0, offsetY = 0) => (route.curved && arcPath(route, offsetX, offsetY)) || pathFromPoints(route.points, offsetX, offsetY);
|
|
207
|
+
|
|
208
|
+
function routeSequenceEdge(edge, source, target, selfIndex, context) {
|
|
209
|
+
const { graph, executions, scopes, pairs } = context;
|
|
210
|
+
const y = sequenceEndpointY(edge, 'send');
|
|
211
|
+
const endY = sequenceEndpointY(edge, 'receive');
|
|
212
|
+
const sourceExecution = executionAt(executions, source.id, edge, 'send', y, scopes.get(edge.id));
|
|
213
|
+
const targetExecution = executionAt(executions, target.id, edge, 'receive', endY, scopes.get(edge.id));
|
|
214
|
+
const forward = source.id === target.id || center(source).x <= center(target).x;
|
|
215
|
+
const sourceX = sourceExecution ? sourceExecution.x + (forward ? sourceExecution.width : 0) : center(source).x;
|
|
216
|
+
const targetX = targetExecution ? targetExecution.x + (source.id === target.id || !forward ? targetExecution.width : 0) : center(target).x;
|
|
217
|
+
const label = sequenceMessageLabel(graph, edge, pairs, scopes);
|
|
218
|
+
const available = source.id === target.id ? LAYOUT_TARGETS.labelWidth : Math.min(LAYOUT_TARGETS.labelWidth, Math.max(1, Math.abs(targetX - sourceX) - 32 - 12));
|
|
219
|
+
const layout = edgeLabelLayout(label, available);
|
|
220
|
+
const labelSize = { width: layout.width, height: layout.height };
|
|
221
|
+
if (source.id === target.id) {
|
|
222
|
+
const extent = 48 + selfIndex * LAYOUT_LIMITS.parallelGap;
|
|
223
|
+
const start = { x: sourceX, y };
|
|
224
|
+
const end = { x: targetX, y: endY };
|
|
225
|
+
const points = edge.route?.via?.length
|
|
226
|
+
? routePointsWithWaypoints(start, end, 'right', 'right', edge.route.via)
|
|
227
|
+
: [start, { x: sourceX + extent, y }, { x: sourceX + extent, y: y + 30 }, end];
|
|
228
|
+
return { points, label, labelLines: layout.lines, labelSize, labelPoint: edge.route?.labelAt ?? { x: Math.max(...points.map(p => p.x)) + 8 + labelSize.width / 2, y: y + 15 }, sourceSide: 'right', targetSide: 'right' };
|
|
229
|
+
}
|
|
230
|
+
const points = edge.route?.via?.length
|
|
231
|
+
? routePointsWithWaypoints({ x: sourceX, y }, { x: targetX, y }, sourceX <= targetX ? 'right' : 'left', sourceX <= targetX ? 'left' : 'right', edge.route.via)
|
|
232
|
+
: [{ x: sourceX, y }, { x: targetX, y }];
|
|
233
|
+
return { points, label, labelLines: layout.lines, labelSize, labelPoint: edge.route?.labelAt ?? { x: (sourceX + targetX) / 2, y: y - 6 - labelSize.height / 2 }, sourceSide: sourceX <= targetX ? 'right' : 'left', targetSide: sourceX <= targetX ? 'left' : 'right' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createEdgeRoutes(graph) {
|
|
237
|
+
const type = diagramTypeOf(graph);
|
|
238
|
+
const stub = getDiagram(type).endpointStub ?? LAYOUT_LIMITS.endpoint;
|
|
239
|
+
const nodeById = new Map(graph.nodes.map(node => [node.id, node]));
|
|
240
|
+
const prepared = graph.edges.map(edge => {
|
|
241
|
+
const source = nodeById.get(edge.source);
|
|
242
|
+
const target = nodeById.get(edge.target);
|
|
243
|
+
return { edge, source, target, self: source.id === target.id, ...sidesFor(source, target) };
|
|
244
|
+
});
|
|
245
|
+
const sequenceContext = type === 'sequence' ? { graph, executions: sequenceExecutions(graph), scopes: operandScopes(graph), pairs: sequencePairs(graph) } : null;
|
|
246
|
+
const endpointBuckets = new Map();
|
|
247
|
+
for (const item of prepared.filter(item => !getDiagram(type).sequence && !item.self && !item.edge.route?.via?.length)) {
|
|
248
|
+
for (const [role, node, side, opposite] of [
|
|
249
|
+
['source', item.source, item.sourceSide, item.target],
|
|
250
|
+
['target', item.target, item.targetSide, item.source]
|
|
251
|
+
]) {
|
|
252
|
+
const key = `${node.id}:${side}`;
|
|
253
|
+
const coordinate = item.orientation === 'horizontal' ? center(opposite).y : center(opposite).x;
|
|
254
|
+
if (!endpointBuckets.has(key)) endpointBuckets.set(key, []);
|
|
255
|
+
endpointBuckets.get(key).push({ id: item.edge.id, role, coordinate });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const offsets = new Map();
|
|
259
|
+
for (const bucket of endpointBuckets.values()) {
|
|
260
|
+
bucket.sort((a, b) => a.coordinate - b.coordinate || a.id.localeCompare(b.id) || a.role.localeCompare(b.role));
|
|
261
|
+
bucket.forEach((item, index) => offsets.set(`${item.id}:${item.role}`, (index - (bucket.length - 1) / 2) * LAYOUT_LIMITS.parallelGap));
|
|
262
|
+
}
|
|
263
|
+
const selfCounts = new Map();
|
|
264
|
+
const selfOrdinals = new Map();
|
|
265
|
+
for (const item of prepared.filter(item => item.self).sort((a, b) => (a.edge.order ?? 0) - (b.edge.order ?? 0) || (a.edge.id < b.edge.id ? -1 : 1))) {
|
|
266
|
+
const ordinal = selfCounts.get(item.source.id) ?? 0;
|
|
267
|
+
selfOrdinals.set(item.edge.id, ordinal); selfCounts.set(item.source.id, ordinal + 1);
|
|
268
|
+
}
|
|
269
|
+
const routes = new Map();
|
|
270
|
+
for (const item of prepared) {
|
|
271
|
+
let route;
|
|
272
|
+
if (getDiagram(type).sequence) {
|
|
273
|
+
const selfIndex = selfOrdinals.get(item.edge.id) ?? 0;
|
|
274
|
+
route = routeSequenceEdge(item.edge, item.source, item.target, selfIndex, sequenceContext);
|
|
275
|
+
} else if (item.self) {
|
|
276
|
+
const selfIndex = selfOrdinals.get(item.edge.id);
|
|
277
|
+
const right = item.source.position.x + item.source.size.width;
|
|
278
|
+
const middleY = center(item.source).y;
|
|
279
|
+
const extent = 48 + selfIndex * LAYOUT_LIMITS.parallelGap;
|
|
280
|
+
const label = visibleEdgeLabel(item.edge, type, graph.meta?.locale);
|
|
281
|
+
const labelSize = estimateLabelSize(label);
|
|
282
|
+
const waypoints = item.edge.route?.via;
|
|
283
|
+
const sourceEndpoint = waypoints?.length && waypointEndpoint(item.source, waypoints[0], 'right', type);
|
|
284
|
+
const targetEndpoint = waypoints?.length && waypointEndpoint(item.target, waypoints.at(-1), 'right', type);
|
|
285
|
+
const sourceSide = sourceEndpoint ? sourceEndpoint.side : 'right', targetSide = targetEndpoint ? targetEndpoint.side : 'right';
|
|
286
|
+
const start = sourceEndpoint ? sourceEndpoint.point : anchor(item.source, 'right', -16 - selfIndex * 12, type);
|
|
287
|
+
const end = targetEndpoint ? targetEndpoint.point : anchor(item.source, 'right', 16 + selfIndex * 12, type);
|
|
288
|
+
route = {
|
|
289
|
+
points: item.edge.route?.via?.length
|
|
290
|
+
? routePointsWithWaypoints(start, end, sourceSide, targetSide, item.edge.route.via, stub)
|
|
291
|
+
: [start, { x: right + extent, y: start.y }, { x: right + extent, y: end.y }, end],
|
|
292
|
+
label,
|
|
293
|
+
labelPoint: item.edge.route?.labelAt ?? { x: right + extent + 8 + labelSize.width / 2, y: middleY },
|
|
294
|
+
sourceSide, targetSide,
|
|
295
|
+
curved: Boolean(getDiagram(type).curvedSelfLoops)
|
|
296
|
+
};
|
|
297
|
+
} else {
|
|
298
|
+
const waypoints = item.edge.route?.via;
|
|
299
|
+
const sourceEndpoint = waypoints?.length && waypointEndpoint(item.source, waypoints[0], item.sourceSide, type);
|
|
300
|
+
const targetEndpoint = waypoints?.length && waypointEndpoint(item.target, waypoints.at(-1), item.targetSide, type);
|
|
301
|
+
const sourceSide = sourceEndpoint ? sourceEndpoint.side : item.sourceSide;
|
|
302
|
+
const targetSide = targetEndpoint ? targetEndpoint.side : item.targetSide;
|
|
303
|
+
const routedWaypoints = waypoints ? [...waypoints] : [];
|
|
304
|
+
if (sourceEndpoint && ['decision', 'choice'].includes(item.source.kind) && ['left', 'right'].includes(sourceSide)) {
|
|
305
|
+
routedWaypoints.unshift({ x: outward(sourceEndpoint.point, sourceSide, stub).x, y: waypoints[0].y });
|
|
306
|
+
}
|
|
307
|
+
if (targetEndpoint && ['decision', 'choice'].includes(item.target.kind) && ['top', 'bottom'].includes(targetSide)) {
|
|
308
|
+
routedWaypoints.push({ x: waypoints.at(-1).x, y: outward(targetEndpoint.point, targetSide, stub).y });
|
|
309
|
+
}
|
|
310
|
+
const points = item.edge.route?.via?.length
|
|
311
|
+
? routePointsWithWaypoints(sourceEndpoint.point, targetEndpoint.point, sourceSide, targetSide, routedWaypoints, stub)
|
|
312
|
+
: routeBetween(item.source, item.target, item, offsets.get(`${item.edge.id}:source`) ?? 0, offsets.get(`${item.edge.id}:target`) ?? 0, stub, type);
|
|
313
|
+
const label = visibleEdgeLabel(item.edge, type, graph.meta?.locale);
|
|
314
|
+
route = { points, label, labelPoint: item.edge.route?.labelAt ?? bestLabelPoint(points, estimateLabelSize(label), item.source, item.target), sourceSide, targetSide };
|
|
315
|
+
}
|
|
316
|
+
const labelSize = route.labelSize ?? estimateLabelSize(route.label);
|
|
317
|
+
const labelLines = route.labelLines ?? edgeLabelLayout(route.label).lines, labelParts = getDiagram(type).edgeLabelParts?.(item.edge);
|
|
318
|
+
routes.set(item.edge.id, { ...route, endpointLabels: getDiagram(type).endpointLabels?.(item.edge, route.points) ?? [], labelLines, ...(labelParts ? { labelRuns: labelRunsByLine(labelLines, labelParts) ?? undefined } : {}), path: pathFromRoute(route), labelBox: { x: route.labelPoint.x - labelSize.width / 2, y: route.labelPoint.y - labelSize.height / 2, ...labelSize } });
|
|
319
|
+
}
|
|
320
|
+
return routes;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function boxesIntersect(a, b, margin = 0) {
|
|
324
|
+
return a.x < b.x + b.width + margin
|
|
325
|
+
&& a.x + a.width > b.x - margin
|
|
326
|
+
&& a.y < b.y + b.height + margin
|
|
327
|
+
&& a.y + a.height > b.y - margin;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function occupiedBox(node, type) {
|
|
331
|
+
if (!getDiagram(type).sequence) return { ...node.position, ...node.size };
|
|
332
|
+
return { ...node.position, width: node.size.width, height: Math.min(node.size.height, sequenceHeaderHeight(node)) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function boxDistance(first, second) {
|
|
336
|
+
const horizontal = Math.max(first.x - second.x - second.width, second.x - first.x - first.width, 0);
|
|
337
|
+
const vertical = Math.max(first.y - second.y - second.height, second.y - first.y - first.height, 0);
|
|
338
|
+
return Math.hypot(horizontal, vertical);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function pointOnNodeSide(point, node, side, type) {
|
|
342
|
+
const bounds = routingBounds(node, type);
|
|
343
|
+
if ((side === 'left' || side === 'right') && (point.y < bounds.y || point.y > bounds.y + bounds.height)) return false;
|
|
344
|
+
if ((side === 'top' || side === 'bottom') && (point.x < bounds.x || point.x > bounds.x + bounds.width)) return false;
|
|
345
|
+
const middle = center(node);
|
|
346
|
+
const offset = side === 'left' || side === 'right' ? point.y - middle.y : point.x - middle.x;
|
|
347
|
+
const expected = anchor(node, side, offset, type);
|
|
348
|
+
return Math.hypot(point.x - expected.x, point.y - expected.y) < 1e-7;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function segmentCrossesBox(start, end, box) {
|
|
352
|
+
if (start.y === end.y) {
|
|
353
|
+
return start.y > box.y && start.y < box.y + box.height
|
|
354
|
+
&& Math.max(Math.min(start.x, end.x), box.x) < Math.min(Math.max(start.x, end.x), box.x + box.width);
|
|
355
|
+
}
|
|
356
|
+
if (start.x === end.x) {
|
|
357
|
+
return start.x > box.x && start.x < box.x + box.width
|
|
358
|
+
&& Math.max(Math.min(start.y, end.y), box.y) < Math.min(Math.max(start.y, end.y), box.y + box.height);
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function routingBounds(node, type) {
|
|
364
|
+
if (type === 'state' && ['initial', 'final'].includes(node.kind)) {
|
|
365
|
+
const radius = node.kind === 'initial' ? 12 : 13, middle = center(node);
|
|
366
|
+
return { x: node.position.x + stateSymbolX(node) - radius, y: middle.y - radius, width: radius * 2, height: radius * 2 };
|
|
367
|
+
}
|
|
368
|
+
if (type === 'usecase' && node.kind === 'actor') {
|
|
369
|
+
const top = actorTop(node), middle = center(node);
|
|
370
|
+
return { x: middle.x - 20, y: node.position.y + top + 2, width: 40, height: 74 };
|
|
371
|
+
}
|
|
372
|
+
if (type === 'deployment' && node.kind === 'device') return { x: node.position.x + 16, y: node.position.y, width: node.size.width - 32, height: node.size.height };
|
|
373
|
+
if (['architecture', 'deployment'].includes(type) && node.kind === 'database') return { x: node.position.x + 12, y: node.position.y + 2, width: node.size.width - 24, height: node.size.height - 4 };
|
|
374
|
+
return occupiedBox(node, type);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function segmentCrossesNode(start, end, node, type) {
|
|
378
|
+
if (type === 'state' && ['initial', 'final'].includes(node.kind) && node.subtitle) {
|
|
379
|
+
const area = getDiagram(type).textArea(node);
|
|
380
|
+
if (segmentCrossesBox(start, end, { ...area, x: node.position.x + area.x, y: node.position.y + area.y })) return true;
|
|
381
|
+
}
|
|
382
|
+
const bounds = routingBounds(node, type);
|
|
383
|
+
if (type === 'sequence') return segmentCrossesBox(start, end, bounds);
|
|
384
|
+
if (type === 'usecase' && node.kind === 'actor') return segmentCrossesBox(start, end, bounds);
|
|
385
|
+
const middle = center(node);
|
|
386
|
+
if (start.y === end.y) {
|
|
387
|
+
if (start.y <= bounds.y || start.y >= bounds.y + bounds.height) return false;
|
|
388
|
+
const offset = start.y - middle.y;
|
|
389
|
+
const left = anchor(node, 'left', offset, type).x, right = anchor(node, 'right', offset, type).x;
|
|
390
|
+
return Math.max(Math.min(start.x, end.x), left) < Math.min(Math.max(start.x, end.x), right) - 1e-7;
|
|
391
|
+
}
|
|
392
|
+
if (start.x === end.x) {
|
|
393
|
+
if (start.x <= bounds.x || start.x >= bounds.x + bounds.width) return false;
|
|
394
|
+
const offset = start.x - middle.x;
|
|
395
|
+
const top = anchor(node, 'top', offset, type).y, bottom = anchor(node, 'bottom', offset, type).y;
|
|
396
|
+
return Math.max(Math.min(start.y, end.y), top) < Math.min(Math.max(start.y, end.y), bottom) - 1e-7;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function groupBorders(group) {
|
|
402
|
+
const left = group.position.x;
|
|
403
|
+
const right = left + group.size.width;
|
|
404
|
+
const top = group.position.y;
|
|
405
|
+
const bottom = top + group.size.height;
|
|
406
|
+
return [
|
|
407
|
+
[{ x: left, y: top }, { x: right, y: top }],
|
|
408
|
+
[{ x: right, y: top }, { x: right, y: bottom }],
|
|
409
|
+
[{ x: left, y: bottom }, { x: right, y: bottom }],
|
|
410
|
+
[{ x: left, y: top }, { x: left, y: bottom }]
|
|
411
|
+
];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function groupHeadingBoxes(group) {
|
|
415
|
+
const fragment = ['alt', 'opt', 'loop', 'par'].includes(group.kind), heading = groupHeadingLayout(group);
|
|
416
|
+
const fragmentHeading = fragment ? fragmentHeadingLayout(group) : null;
|
|
417
|
+
const height = Math.min(fragment ? Math.max(36, fragmentHeading.height + 20) : heading.height, group.size.height);
|
|
418
|
+
const labelWidth = Math.min(group.size.width, (fragment ? fragmentHeading.width : heading.width) + 32);
|
|
419
|
+
const boxes = [{ x: group.position.x, y: group.position.y, width: labelWidth, height }];
|
|
420
|
+
if (['alt', 'opt', 'loop'].includes(group.kind)) boxes.push({ x: group.position.x + group.size.width - 52, y: group.position.y, width: 52, height });
|
|
421
|
+
return boxes;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function sharedSegmentLength(firstStart, firstEnd, secondStart, secondEnd) {
|
|
425
|
+
if (firstStart.y === firstEnd.y && secondStart.y === secondEnd.y && firstStart.y === secondStart.y) {
|
|
426
|
+
return Math.max(0, Math.min(Math.max(firstStart.x, firstEnd.x), Math.max(secondStart.x, secondEnd.x))
|
|
427
|
+
- Math.max(Math.min(firstStart.x, firstEnd.x), Math.min(secondStart.x, secondEnd.x)));
|
|
428
|
+
}
|
|
429
|
+
if (firstStart.x === firstEnd.x && secondStart.x === secondEnd.x && firstStart.x === secondStart.x) {
|
|
430
|
+
return Math.max(0, Math.min(Math.max(firstStart.y, firstEnd.y), Math.max(secondStart.y, secondEnd.y))
|
|
431
|
+
- Math.max(Math.min(firstStart.y, firstEnd.y), Math.min(secondStart.y, secondEnd.y)));
|
|
432
|
+
}
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function routeCrossings(first, firstPoints, second, secondPoints) {
|
|
437
|
+
const points = new Map();
|
|
438
|
+
const ends = (edge, route) => [{ id: edge.source, point: route[0] }, { id: edge.target, point: route.at(-1) }];
|
|
439
|
+
const samePoint = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < .001;
|
|
440
|
+
for (let i = 1; i < firstPoints.length; i++) for (let j = 1; j < secondPoints.length; j++) {
|
|
441
|
+
const a = firstPoints[i - 1], b = firstPoints[i], c = secondPoints[j - 1], d = secondPoints[j];
|
|
442
|
+
const dx = b.x - a.x, dy = b.y - a.y, ex = d.x - c.x, ey = d.y - c.y, det = dx * ey - dy * ex;
|
|
443
|
+
if (Math.abs(det) < 1e-8) continue; // Collinear overlap is a separate hard rule.
|
|
444
|
+
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / det, u = ((c.x - a.x) * dy - (c.y - a.y) * dx) / det;
|
|
445
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) continue;
|
|
446
|
+
const point = { x: +(a.x + t * dx).toFixed(3), y: +(a.y + t * dy).toFixed(3) };
|
|
447
|
+
if (ends(first, firstPoints).some(left => ends(second, secondPoints).some(right => left.id === right.id && samePoint(left.point, point) && samePoint(right.point, point)))) continue;
|
|
448
|
+
points.set(`${point.x},${point.y}`, point);
|
|
449
|
+
}
|
|
450
|
+
return [...points.values()].sort((a, b) => a.x - b.x || a.y - b.y);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function auditGraphLayout(graph) {
|
|
454
|
+
const type = diagramTypeOf(graph);
|
|
455
|
+
const crossings = [];
|
|
456
|
+
const routes = createEdgeRoutes(graph);
|
|
457
|
+
const errors = [];
|
|
458
|
+
const warnings = [], diagnostics = [];
|
|
459
|
+
const elements = new Map([...graph.nodes, ...(graph.groups ?? []), ...sequenceExecutions(graph)].map(item => [item.id, item]));
|
|
460
|
+
const fail = (ruleId, elementIds, message) => {
|
|
461
|
+
errors.push(message);
|
|
462
|
+
const bounds = elementIds.flatMap(id => {
|
|
463
|
+
const item = elements.get(id);
|
|
464
|
+
if (item) return [item.position ? { ...item.position, ...item.size } : { x: item.x, y: item.y, width: item.width, height: item.height }];
|
|
465
|
+
const route = routes.get(id);
|
|
466
|
+
return route ? route.points.map(point => ({ ...point, width: 0, height: 0 })) : [];
|
|
467
|
+
});
|
|
468
|
+
diagnostics.push({ ruleId, severity: 'error', diagramType: type, elementIds, bounds, measured: message, required: 'clear full-size geometry', remediation: message.includes(';') ? message.split(';').slice(1).join(';').trim() : 'Adjust the identified elements while preserving the complete model.' });
|
|
469
|
+
};
|
|
470
|
+
if (type === 'sequence') {
|
|
471
|
+
for (const node of graph.nodes) if (node.size.height < sequenceHeaderHeight(node)) fail('sequence.header', [node.id], `layout: sequence node ${node.id} is shorter than its visible header`);
|
|
472
|
+
for (const group of graph.groups ?? []) {
|
|
473
|
+
const fragment = sequenceFragment(group, routes, graph.meta.locale, graph.groups ?? [], sequenceExecutions(graph));
|
|
474
|
+
for (const message of fragment.errors) fail('sequence.fragment', [group.id], message);
|
|
475
|
+
warnings.push(...fragment.warnings);
|
|
476
|
+
}
|
|
477
|
+
const executions = sequenceExecutions(graph);
|
|
478
|
+
for (const edge of graph.edges) {
|
|
479
|
+
const route = routes.get(edge.id);
|
|
480
|
+
for (const execution of executions) if (intersects(route.labelBox, execution, 2)) fail('sequence.label-execution', [edge.id, execution.id], `layout: edge ${edge.id} label overlaps execution ${execution.id}`);
|
|
481
|
+
for (const other of graph.edges) {
|
|
482
|
+
if (segmentBoxes(routes.get(other.id)).some(box => intersects(route.labelBox, box))) fail('sequence.message-clearance', [edge.id, other.id], `layout: sequence edge ${edge.id} label needs 6px clearance from message ${other.id}; shorten the label or adjust participant spacing/route hints`);
|
|
483
|
+
}
|
|
484
|
+
if (route.points.some(p => p.y > Math.min(...[edge.source, edge.target].map(id => { const n = graph.nodes.find(n => n.id === id); return n.position.y + n.size.height; })))) fail('sequence.lifeline', [edge.id, edge.source, edge.target], `layout: sequence edge ${edge.id} exceeds its participant lifeline`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (getDiagram(type).cardLayout) for (const node of graph.nodes) {
|
|
488
|
+
if (node.size.height < 100) continue;
|
|
489
|
+
const { minHeight } = cardTextLayout(node);
|
|
490
|
+
if (node.size.height < minHeight) fail('text.card-height', [node.id], `layout: node ${node.id} text needs at least ${minHeight}px height at 20/16px; enlarge the node and check its route clearance`);
|
|
491
|
+
}
|
|
492
|
+
for (let left = 0; left < graph.nodes.length; left += 1) {
|
|
493
|
+
for (let right = left + 1; right < graph.nodes.length; right += 1) {
|
|
494
|
+
const first = graph.nodes[left];
|
|
495
|
+
const second = graph.nodes[right];
|
|
496
|
+
const firstBox = occupiedBox(first, type);
|
|
497
|
+
const secondBox = occupiedBox(second, type);
|
|
498
|
+
if (boxesIntersect(firstBox, secondBox)) fail('shape.node-overlap', [first.id, second.id], `layout: nodes ${first.id} and ${second.id} overlap`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const labeledEdges = graph.edges.filter(edge => routes.get(edge.id).label);
|
|
502
|
+
for (let left = 0; left < labeledEdges.length; left += 1) {
|
|
503
|
+
for (let right = left + 1; right < labeledEdges.length; right += 1) {
|
|
504
|
+
const first = labeledEdges[left];
|
|
505
|
+
const second = labeledEdges[right];
|
|
506
|
+
if (boxesIntersect(routes.get(first.id).labelBox, routes.get(second.id).labelBox)) fail('label.overlap', [first.id, second.id], `layout: edge labels ${first.id} and ${second.id} overlap`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (let left = 0; left < graph.edges.length; left += 1) {
|
|
510
|
+
for (let right = left + 1; right < graph.edges.length; right += 1) {
|
|
511
|
+
const first = graph.edges[left];
|
|
512
|
+
const second = graph.edges[right];
|
|
513
|
+
const firstPoints = routes.get(first.id).points;
|
|
514
|
+
const secondPoints = routes.get(second.id).points;
|
|
515
|
+
let longest = 0;
|
|
516
|
+
for (let firstIndex = 1; firstIndex < firstPoints.length; firstIndex += 1) {
|
|
517
|
+
for (let secondIndex = 1; secondIndex < secondPoints.length; secondIndex += 1) {
|
|
518
|
+
longest = Math.max(longest, sharedSegmentLength(firstPoints[firstIndex - 1], firstPoints[firstIndex], secondPoints[secondIndex - 1], secondPoints[secondIndex]));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (longest > LAYOUT_LIMITS.endpoint) fail('route.shared-segment', [first.id, second.id], `layout: edges ${first.id} and ${second.id} share a route segment longer than ${LAYOUT_LIMITS.endpoint}px`);
|
|
522
|
+
const points = routeCrossings(first, firstPoints, second, secondPoints);
|
|
523
|
+
if (points.length) {
|
|
524
|
+
crossings.push({ ruleId: 'route.point-crossing', severity: 'info', diagramType: type, elementIds: [first.id, second.id].sort(), measured: points.length, required: null, bounds: points.map(point => ({ ...point, width: 0, height: 0 })), repeated: Math.max(0, points.length - 1), remediation: 'Keep each relation traceable; prefer fewer repeated crossings without overlapping channels.' });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const edge of graph.edges) {
|
|
529
|
+
const route = routes.get(edge.id);
|
|
530
|
+
const source = graph.nodes.find(node => node.id === edge.source);
|
|
531
|
+
const target = graph.nodes.find(node => node.id === edge.target);
|
|
532
|
+
if (!getDiagram(type).sequence && (!pointOnNodeSide(route.points[0], source, route.sourceSide, type) || !pointOnNodeSide(route.points.at(-1), target, route.targetSide, type))) {
|
|
533
|
+
fail('route.anchor', [edge.id, edge.source, edge.target], `layout: edge ${edge.id} exceeds an endpoint side; enlarge the node or add route hints`);
|
|
534
|
+
}
|
|
535
|
+
if (getDiagram(type).cardinalities) {
|
|
536
|
+
const erStub = getDiagram(type).endpointStub;
|
|
537
|
+
for (const [role, point, neighbor, side, cardinality, hint] of [
|
|
538
|
+
['source', route.points[0], route.points[1], route.sourceSide, edge.sourceCardinality, edge.route?.via?.[0]],
|
|
539
|
+
['target', route.points.at(-1), route.points.at(-2), route.targetSide, edge.targetCardinality, edge.route?.via?.at(-1)]
|
|
540
|
+
]) {
|
|
541
|
+
const direction = outward(point, side, 1);
|
|
542
|
+
const projection = other => (other.x - point.x) * (direction.x - point.x) + (other.y - point.y) * (direction.y - point.y);
|
|
543
|
+
if (projection(neighbor) < erStub || (hint && projection(hint) < erStub)) {
|
|
544
|
+
fail('route.er-stub', [edge.id], `layout: ER edge ${edge.id} ${role} needs a ${erStub}px outward straight segment; move the route hint or enlarge the gap`);
|
|
545
|
+
}
|
|
546
|
+
const mark = cardinalityMarks(cardinality, point, neighbor);
|
|
547
|
+
for (const node of graph.nodes) {
|
|
548
|
+
if (mark.bounds && boxesIntersect(mark.bounds, { ...node.position, ...node.size })) fail('marker.er-node', [edge.id, node.id], `layout: ER edge ${edge.id} ${role} cardinality overlaps node ${node.id}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (route.points.slice(1, -1).some((point, index) => {
|
|
552
|
+
const before = route.points[index];
|
|
553
|
+
const after = route.points[index + 2];
|
|
554
|
+
return before.x === point.x && point.x === after.x && (point.y - before.y) * (after.y - point.y) < 0
|
|
555
|
+
|| before.y === point.y && point.y === after.y && (point.x - before.x) * (after.x - point.x) < 0;
|
|
556
|
+
})) fail('route.er-reversal', [edge.id], `layout: ER edge ${edge.id} reverses within its route; reserve ${erStub}px endpoint segments or move the route hints`);
|
|
557
|
+
}
|
|
558
|
+
if (getDiagram(type).sequence && edge.source !== edge.target) {
|
|
559
|
+
const available = Math.abs(center(source).x - center(target).x);
|
|
560
|
+
const required = Math.max(160, route.labelBox.width + 32);
|
|
561
|
+
if (available < required) fail('sequence.participant-gap', [edge.id, edge.source, edge.target], `layout: sequence edge ${edge.id} needs ${Math.ceil(required)}px between participants; found ${Math.round(available)}px`);
|
|
562
|
+
}
|
|
563
|
+
for (const node of graph.nodes) {
|
|
564
|
+
for (let index = 1; index < route.points.length; index += 1) {
|
|
565
|
+
if (node.id === edge.source && index === 1 || node.id === edge.target && index === route.points.length - 1) continue;
|
|
566
|
+
if (segmentCrossesNode(route.points[index - 1], route.points[index], node, type)) {
|
|
567
|
+
const kind = edge.source === edge.target && node.id === edge.source ? 'self-loop' : 'edge';
|
|
568
|
+
fail('route.node-obstacle', [edge.id, node.id], `layout: ${kind} ${edge.id} crosses node ${node.id}`);
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (!route.label) continue;
|
|
574
|
+
for (const group of graph.groups ?? []) {
|
|
575
|
+
if (groupHeadingBoxes(group).some(heading => boxesIntersect(route.labelBox, heading))) fail('label.group-heading', [edge.id, group.id], `layout: edge ${edge.id} label overlaps group ${group.id} heading`);
|
|
576
|
+
const borders = groupBorders(group);
|
|
577
|
+
if (borders.some(([start, end]) => segmentCrossesBox(start, end, route.labelBox))) fail('label.group-boundary', [edge.id, group.id], `layout: edge ${edge.id} label overlaps group ${group.id} boundary`);
|
|
578
|
+
}
|
|
579
|
+
for (const node of graph.nodes) {
|
|
580
|
+
if (boxesIntersect(route.labelBox, routingBounds(node, type))) fail('label.node', [edge.id, node.id], `layout: edge ${edge.id} label overlaps node ${node.id}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const edge of graph.edges) {
|
|
584
|
+
const route = routes.get(edge.id);
|
|
585
|
+
for (const group of graph.groups ?? []) {
|
|
586
|
+
const borders = groupBorders(group);
|
|
587
|
+
for (let index = 1; index < route.points.length; index += 1) {
|
|
588
|
+
const start = route.points[index - 1];
|
|
589
|
+
const end = route.points[index];
|
|
590
|
+
if (groupHeadingBoxes(group).some(heading => segmentCrossesBox(start, end, heading))) fail('route.group-heading', [edge.id, group.id], `layout: edge ${edge.id} crosses group ${group.id} heading`);
|
|
591
|
+
if (borders.some(([borderStart, borderEnd]) => sharedSegmentLength(start, end, borderStart, borderEnd) > LAYOUT_LIMITS.endpoint)) fail('route.group-boundary', [edge.id, group.id], `layout: edge ${edge.id} overlaps group ${group.id} boundary`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { errors: [...new Set(errors)], warnings: [...new Set(warnings)], routes, crossings, diagnostics };
|
|
596
|
+
}
|