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,565 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { validateGraph, diagramTypeOf } from './validate-graph.mjs';
|
|
4
|
+
import { getDiagram, canvasBudgetFor } from '../assets/viewer/src/diagrams/registry.js';
|
|
5
|
+
import { stateSymbolX } from '../assets/viewer/src/diagrams/state.js';
|
|
6
|
+
import { minimumNodeSize } from '../assets/viewer/src/layout-measure.js';
|
|
7
|
+
import { graphBounds, occupiedBox, visibleEdgeLabel, groupHeadingBoxes, segmentCrossesBox, createEdgeRoutes } from '../assets/viewer/src/edge-routing.js';
|
|
8
|
+
import { groupHeadingLayout, estimateLabelSize } from '../assets/viewer/src/text-layout.js';
|
|
9
|
+
import { ASPECT_SLACK, LAYOUT_LIMITS, LAYOUT_TARGETS, ratioExcess } from '../assets/viewer/src/layout-spacing.js';
|
|
10
|
+
import { auditLayoutQuality, qualityFailure, requireDiagramQuality } from '../assets/viewer/src/layout-quality.js';
|
|
11
|
+
import { compileSequence } from './compile-sequence.mjs';
|
|
12
|
+
|
|
13
|
+
export const LAYOUT_VERSION = 'adaptive-v2-elkjs-0.11.0';
|
|
14
|
+
export const CANDIDATE_COUNT = 6;
|
|
15
|
+
// Hang guard for one ELK solve, not a budget for a compile: a slow or busy machine only takes longer.
|
|
16
|
+
export const LAYOUT_TIMEOUT_MS = 30_000;
|
|
17
|
+
// A layered result whose width/height ratio leaves the band [1/ASPECT_BAND, ASPECT_BAND] by more than ASPECT_SLACK is folded:
|
|
18
|
+
// a top-down layout into columns when too tall, a left-to-right one into rows when too wide. The fewest segments that bring
|
|
19
|
+
// the shape back within the slack win, so the graph's own shape decides between landscape and portrait. Ranked layouts and
|
|
20
|
+
// branching state charts never fold.
|
|
21
|
+
export const FOLD_MAX = 5;
|
|
22
|
+
// Fixed-position ports keep the order their relations were created in, so edge order alone decides which relations cross at
|
|
23
|
+
// a node. When the best candidates still have crossings, a bounded local search moves only the relations that cross: it
|
|
24
|
+
// swaps their ports within a node side, swaps which branch of a decision leaves on which side, and swaps their lanes across
|
|
25
|
+
// fold cuts. A move is kept only when the candidate scores better, so it is deterministic and never worse than the plain
|
|
26
|
+
// result; it stops with no crossings, no gain, or when its evaluation budget is spent. The budget counts evaluations, never
|
|
27
|
+
// time, so a slow or busy machine lays out the same graph the same way; only LAYOUT_TIMEOUT_MS can end a compile, and that
|
|
28
|
+
// is an error, not a different layout.
|
|
29
|
+
export const REFINE_CANDIDATES = 2;
|
|
30
|
+
export const REFINE_EVALUATIONS = 60;
|
|
31
|
+
// Candidates whose width/height ratio is within this factor of the accepted band rank equal on shape, so a crossing is never
|
|
32
|
+
// traded for a slightly better aspect ratio; only a shape beyond it (a long strip) outranks the crossings, and only by a
|
|
33
|
+
// whole SHAPE_STEP, so a strip does not take on crossings for a marginally better ratio.
|
|
34
|
+
export const SHAPE_TIE = 1.3;
|
|
35
|
+
export const SHAPE_STEP = .25;
|
|
36
|
+
export const shapeRank = excess => excess <= SHAPE_TIE ? 1 : Math.ceil(excess / SHAPE_STEP) * SHAPE_STEP;
|
|
37
|
+
const stable = items => [...items].sort((a, b) => (a.layout?.rank ?? 0) - (b.layout?.rank ?? 0) || (a.layout?.order ?? 0) - (b.layout?.order ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
38
|
+
const round = value => +value.toFixed(3);
|
|
39
|
+
const box = item => ({ ...item.position, ...item.size });
|
|
40
|
+
const contains = (a, b) => a.x <= b.x && a.y <= b.y && a.x + a.width >= b.x + b.width && a.y + a.height >= b.y + b.height;
|
|
41
|
+
const intersects = (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;
|
|
42
|
+
|
|
43
|
+
function semanticDigest(graph, migration = []) {
|
|
44
|
+
const model = structuredClone(graph);
|
|
45
|
+
for (const key of ['nodes', 'edges', 'groups', 'executions']) if (model[key]) model[key].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
46
|
+
for (const item of [...model.nodes, ...(model.groups ?? [])]) { delete item.position; delete item.size; }
|
|
47
|
+
for (const edge of model.edges) delete edge.route;
|
|
48
|
+
for (const item of migration) delete [...model.nodes, ...(model.groups ?? [])].find(element => element.id === item.elementId)[item.field];
|
|
49
|
+
if (model.layout) { delete model.layout.version; delete model.layout.strategy; if (!Object.keys(model.layout).length) delete model.layout; }
|
|
50
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
51
|
+
return createHash('sha256').update(JSON.stringify(canonical(model))).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function migrateOwnership(graph) {
|
|
55
|
+
const migrated = [], groups = graph.groups ?? [];
|
|
56
|
+
if (getDiagram(diagramTypeOf(graph)).sequence) return migrated;
|
|
57
|
+
const infer = item => {
|
|
58
|
+
if (!item.position || !item.size) return undefined;
|
|
59
|
+
const candidates = groups.filter(group => group.id !== item.id && group.position && group.size && intersects(box(group), box(item)));
|
|
60
|
+
const containers = candidates.filter(group => contains(box(group), box(item)));
|
|
61
|
+
const parents = containers.filter(group => !containers.some(other => group !== other && contains(box(group), box(other))));
|
|
62
|
+
// Child groups contained by this group are not ambiguous parents.
|
|
63
|
+
if (candidates.some(group => !containers.includes(group) && !contains(box(item), box(group))) || parents.length > 1
|
|
64
|
+
|| containers.some(group => contains(box(item), box(group)))) throw new Error(`Ambiguous legacy containment for ${item.id}; specify groupId/parentId explicitly`);
|
|
65
|
+
return parents[0]?.id;
|
|
66
|
+
};
|
|
67
|
+
for (const [items, key] of [[groups, 'parentId'], [graph.nodes, 'groupId']]) for (const item of items) {
|
|
68
|
+
if (item[key] !== undefined) continue;
|
|
69
|
+
const id = infer(item);
|
|
70
|
+
if (id) { item[key] = id; migrated.push({ elementId: item.id, field: key, value: id, basis: 'unique-geometric-containment' }); }
|
|
71
|
+
}
|
|
72
|
+
return migrated;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function elkInput(graph, candidate, hints = {}) {
|
|
76
|
+
const type = diagramTypeOf(graph), diagram = getDiagram(type), down = !['er', 'deployment', 'dataflow', 'usecase'].includes(type);
|
|
77
|
+
const spacing = LAYOUT_TARGETS.layerGap + [0, 16, 48][candidate % 3];
|
|
78
|
+
const portGap = candidate < 3 ? 24 : 48;
|
|
79
|
+
const feedback = new Set();
|
|
80
|
+
if (['flowchart', 'state'].includes(type)) {
|
|
81
|
+
const seen = new Set(), active = new Set();
|
|
82
|
+
const visit = id => {
|
|
83
|
+
if (seen.has(id)) return;
|
|
84
|
+
seen.add(id); active.add(id);
|
|
85
|
+
for (const edge of stable(graph.edges.filter(edge => edge.source === id))) {
|
|
86
|
+
if (active.has(edge.target)) feedback.add(edge.id);
|
|
87
|
+
else visit(edge.target);
|
|
88
|
+
}
|
|
89
|
+
active.delete(id);
|
|
90
|
+
};
|
|
91
|
+
for (const node of [...stable(graph.nodes.filter(node => ['start', 'initial'].includes(node.kind))), ...stable(graph.nodes)]) visit(node.id);
|
|
92
|
+
}
|
|
93
|
+
const options = {
|
|
94
|
+
'elk.algorithm': 'layered', 'elk.direction': down ? 'DOWN' : 'RIGHT', 'elk.randomSeed': String(candidate < 3 ? 17 : 29),
|
|
95
|
+
// Without edges or declared layers, pack independent peers instead of forcing one layer.
|
|
96
|
+
'elk.edgeRouting': 'ORTHOGONAL', 'elk.hierarchyHandling': !graph.edges.length && !graph.groups?.length && !graph.nodes.some(node => node.layout?.rank !== undefined || type === 'state' && ['initial', 'final'].includes(node.kind)) ? 'SEPARATE_CHILDREN' : 'INCLUDE_CHILDREN',
|
|
97
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.mergeEdges': 'false',
|
|
98
|
+
'elk.layered.feedbackEdges': String(['flowchart', 'state'].includes(type)),
|
|
99
|
+
'elk.spacing.nodeNode': String(LAYOUT_TARGETS.nodeGap), 'elk.spacing.componentComponent': String(LAYOUT_TARGETS.nodeGap), 'elk.layered.spacing.nodeNodeBetweenLayers': String(spacing),
|
|
100
|
+
'elk.spacing.portPort': '24',
|
|
101
|
+
'elk.spacing.edgeEdge': String(portGap), 'elk.layered.spacing.edgeEdgeBetweenLayers': String(portGap),
|
|
102
|
+
'elk.spacing.edgeNode': String(Math.max(LAYOUT_TARGETS.edgeNodeGap, diagram.endpointStub ?? LAYOUT_LIMITS.endpoint)), 'elk.layered.spacing.edgeNodeBetweenLayers': String(Math.max(LAYOUT_TARGETS.edgeNodeGap, diagram.endpointStub ?? LAYOUT_LIMITS.endpoint)), 'elk.spacing.edgeLabel': String(LAYOUT_LIMITS.labelGap), 'elk.spacing.labelNode': String(LAYOUT_LIMITS.labelGap),
|
|
103
|
+
'elk.padding': '[top=32,left=32,bottom=32,right=32]',
|
|
104
|
+
...(graph.nodes.some(node => node.layout?.rank !== undefined) ? { 'elk.partitioning.activate': 'true' } : {}),
|
|
105
|
+
...(diagram.curvedSelfLoops ? { 'elk.spacing.nodeSelfLoop': '44' } : {})
|
|
106
|
+
};
|
|
107
|
+
const root = { id: '$root', layoutOptions: options, children: [], edges: [] };
|
|
108
|
+
const groups = new Map(stable(graph.groups ?? []).map(group => {
|
|
109
|
+
const heading = groupHeadingLayout({ ...group, size: undefined });
|
|
110
|
+
return [group.id, { id: `g:${group.id}`, children: [], layoutOptions: { ...options,
|
|
111
|
+
'elk.padding': `[top=${heading.height + LAYOUT_LIMITS.groupHeadingGap},left=32,bottom=32,right=32]`,
|
|
112
|
+
'elk.nodeSize.constraints': 'MINIMUM_SIZE', 'elk.nodeSize.minimum': `(${heading.width + 64},0)` } }];
|
|
113
|
+
}));
|
|
114
|
+
const ordered = items => hints.nodes ? [...items].sort((a, b) => hints.nodes.indexOf(a.id) - hints.nodes.indexOf(b.id)) : stable(items);
|
|
115
|
+
const nodes = new Map(ordered(graph.nodes).map(node => [node.id, { id: `n:${node.id}`, ...minimumNodeSize(node, type, graph.meta.locale), ports: [], layoutOptions: { 'elk.portConstraints': 'FIXED_POS', ...(node.layout?.rank === undefined ? {} : { 'elk.partitioning.partition': String(node.layout.rank) }) } }]));
|
|
116
|
+
if (type === 'state') for (const node of graph.nodes) {
|
|
117
|
+
if (node.kind === 'initial' || node.kind === 'final') nodes.get(node.id).layoutOptions['elk.layered.layering.layerConstraint'] = node.kind === 'initial' ? 'FIRST_SEPARATE' : 'LAST_SEPARATE';
|
|
118
|
+
if (node.kind === 'initial') for (const edge of graph.edges.filter(edge => edge.source === node.id)) {
|
|
119
|
+
if (graph.nodes.find(item => item.id === edge.target).kind === 'state') nodes.get(edge.target).layoutOptions['elk.layered.layering.layerConstraint'] = 'FIRST';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const portRoles = new Map(), reversed = new Set(), buckets = new Map();
|
|
123
|
+
const actorPorts = new Map();
|
|
124
|
+
for (const edge of stable(graph.edges)) {
|
|
125
|
+
const reverse = type === 'class' && ['inheritance', 'implementation'].includes(edge.kind);
|
|
126
|
+
if (reverse) reversed.add(edge.id);
|
|
127
|
+
const roles = reverse ? ['target', 'source'] : ['source', 'target'];
|
|
128
|
+
const ports = roles.map((role, i) => {
|
|
129
|
+
let side = edge.source === edge.target ? 'EAST' : down ? i ? 'NORTH' : 'SOUTH' : i ? 'WEST' : 'EAST';
|
|
130
|
+
if (type === 'usecase' && graph.nodes.find(node => node.id === edge[role]).kind === 'actor') {
|
|
131
|
+
const index = actorPorts.get(edge[role]) ?? 0, incident = hints.sides?.get(edge[role]);
|
|
132
|
+
side = graph.edges.filter(item => item.source === edge[role] || item.target === edge[role]).length === 1 ? 'EAST' : ['NORTH', 'EAST', 'SOUTH', 'WEST'][(incident ? incident.indexOf(edge.id) : index) % 4];
|
|
133
|
+
actorPorts.set(edge[role], index + 1);
|
|
134
|
+
}
|
|
135
|
+
if (role === 'source' && ['flowchart', 'state'].includes(type) && ['decision', 'choice'].includes(graph.nodes.find(node => node.id === edge.source).kind)) {
|
|
136
|
+
const branches = hints.sides?.get(edge.source) ?? stable(graph.edges.filter(item => item.source === edge.source && item.target !== edge.source)).map(item => item.id);
|
|
137
|
+
if (branches.length >= 2) side = ['WEST', 'EAST', 'SOUTH'][branches.indexOf(edge.id) % 3];
|
|
138
|
+
}
|
|
139
|
+
const port = { id: `p:${edge.id}:${role}`, width: 0, height: 0, layoutOptions: { 'elk.port.side': side } };
|
|
140
|
+
nodes.get(edge[role]).ports.push(port); portRoles.set(port.id, { side, edge, role }); return port.id;
|
|
141
|
+
});
|
|
142
|
+
const label = visibleEdgeLabel(edge, type, graph.meta.locale), size = estimateLabelSize(label);
|
|
143
|
+
root.edges.push({ id: `e:${edge.id}`, sources: [ports[0]], targets: [ports[1]],
|
|
144
|
+
layoutOptions: { 'elk.layered.priority.direction': String(feedback.has(edge.id) ? 1 : 100) },
|
|
145
|
+
// Labels sit on their own line: ELK routes the edge through the label and reserves its size in the layer gap.
|
|
146
|
+
labels: label ? [{ text: label, ...size, layoutOptions: { 'elk.edgeLabels.placement': 'CENTER', 'elk.edgeLabels.inline': 'true' } }] : [] });
|
|
147
|
+
}
|
|
148
|
+
for (const node of nodes.values()) {
|
|
149
|
+
for (const side of ['NORTH', 'EAST', 'SOUTH', 'WEST']) {
|
|
150
|
+
const count = node.ports.filter(port => portRoles.get(port.id).side === side).length, dimension = ['NORTH', 'SOUTH'].includes(side) ? 'width' : 'height';
|
|
151
|
+
if (count) node[dimension] = Math.max(node[dimension], (count - 1) * portGap + 64);
|
|
152
|
+
}
|
|
153
|
+
for (const side of ['NORTH', 'EAST', 'SOUTH', 'WEST']) {
|
|
154
|
+
const ports = node.ports.filter(port => portRoles.get(port.id).side === side), horizontal = ['NORTH', 'SOUTH'].includes(side);
|
|
155
|
+
buckets.set(`${node.id}:${side}`, ports.map(port => port.id));
|
|
156
|
+
const order = hints.ports?.get(`${node.id}:${side}`);
|
|
157
|
+
if (order) ports.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
|
158
|
+
const dimension = horizontal ? 'width' : 'height';
|
|
159
|
+
ports.forEach((port, i) => {
|
|
160
|
+
const original = graph.nodes.find(item => `n:${item.id}` === node.id);
|
|
161
|
+
const middle = horizontal && type === 'state' && ['initial', 'final'].includes(original.kind) ? stateSymbolX({ ...original, size: node }) : node[dimension] / 2;
|
|
162
|
+
const offset = middle + (i - (ports.length - 1) / 2) * portGap;
|
|
163
|
+
port.x = horizontal ? offset : side === 'WEST' ? 0 : node.width;
|
|
164
|
+
port.y = horizontal ? side === 'NORTH' ? 0 : node.height : offset;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const group of stable(graph.groups ?? [])) (groups.get(group.parentId) ?? root).children.push(groups.get(group.id));
|
|
169
|
+
for (const node of ordered(graph.nodes)) (groups.get(node.groupId) ?? root).children.push(nodes.get(node.id));
|
|
170
|
+
return { root, reversed, stub: diagram.endpointStub ?? LAYOUT_LIMITS.endpoint, portGap, portRoles, buckets };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function applyElk(graph, result, prepared) {
|
|
174
|
+
const output = structuredClone(graph), nodes = new Map(output.nodes.map(node => [`n:${node.id}`, node])), groups = new Map((output.groups ?? []).map(group => [`g:${group.id}`, group]));
|
|
175
|
+
const origins = new Map(), edges = [];
|
|
176
|
+
function visit(item, offset = { x: 0, y: 0 }) {
|
|
177
|
+
const origin = { x: round(offset.x + (item.x ?? 0)), y: round(offset.y + (item.y ?? 0)) };
|
|
178
|
+
origins.set(item.id, origin);
|
|
179
|
+
const target = nodes.get(item.id) ?? groups.get(item.id);
|
|
180
|
+
if (target) { target.position = origin; target.size = { width: round(item.width), height: round(item.height) }; }
|
|
181
|
+
for (const edge of item.edges ?? []) edges.push({ edge, owner: item.id });
|
|
182
|
+
for (const child of item.children ?? []) visit(child, origin);
|
|
183
|
+
}
|
|
184
|
+
visit(result);
|
|
185
|
+
const edgeMap = new Map(output.edges.map(edge => [`e:${edge.id}`, edge]));
|
|
186
|
+
for (const { edge, owner } of edges) {
|
|
187
|
+
const target = edgeMap.get(edge.id), origin = origins.get(edge.container ?? owner);
|
|
188
|
+
if (!target || edge.sections?.length !== 1) throw new Error(`Unsupported ELK route sections for ${edge.id}`);
|
|
189
|
+
const section = edge.sections[0], points = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint].map(point => ({ x: round(point.x + origin.x), y: round(point.y + origin.y) }));
|
|
190
|
+
if (prepared.reversed.has(target.id)) points.reverse();
|
|
191
|
+
const via = [stubPoint(points[0], points[1], prepared.stub), ...points.slice(1, -1), stubPoint(points.at(-1), points.at(-2), prepared.stub)];
|
|
192
|
+
const label = edge.labels?.[0];
|
|
193
|
+
target.route = { via, ...(label ? { labelAt: { x: round(origin.x + label.x + label.width / 2), y: round(origin.y + label.y + label.height / 2) } } : {}) };
|
|
194
|
+
}
|
|
195
|
+
if (edges.length !== output.edges.length) throw new Error('ELK did not return every semantic edge');
|
|
196
|
+
bypassHeadings(output, prepared.portGap);
|
|
197
|
+
return output;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const stubPoint = (a, b, length) => { const distance = Math.hypot(b.x - a.x, b.y - a.y); return { x: round(a.x + (b.x - a.x) / distance * length), y: round(a.y + (b.y - a.y) / distance * length) }; };
|
|
201
|
+
|
|
202
|
+
// Routes can enter a boundary through its title. Bypass only that local title band on edges that carry waypoints;
|
|
203
|
+
// leave all nodes and ownership boundaries in place and run the full route audit afterwards.
|
|
204
|
+
function bypassHeadings(output, portGap) {
|
|
205
|
+
for (const group of stable(output.groups ?? [])) {
|
|
206
|
+
const heading = groupHeadingBoxes(group)[0]; let channel = 0;
|
|
207
|
+
for (const edge of stable(output.edges)) {
|
|
208
|
+
if (!edge.route?.via) continue;
|
|
209
|
+
const points = edge.route.via, via = [];
|
|
210
|
+
for (let i = 0; i < points.length; i++) {
|
|
211
|
+
const a = points[i], b = points[i + 1]; via.push(a);
|
|
212
|
+
if (!b || a.x !== b.x || !segmentCrossesBox(a, b, heading)) continue;
|
|
213
|
+
const margin = LAYOUT_LIMITS.labelGap, x = heading.x + heading.width + margin + channel++ * portGap;
|
|
214
|
+
const top = Math.max(Math.min(a.y, b.y), heading.y - margin), bottom = Math.min(Math.max(a.y, b.y), heading.y + heading.height + margin);
|
|
215
|
+
const [enter, leave] = a.y < b.y ? [top, bottom] : [bottom, top];
|
|
216
|
+
via.push({ x: a.x, y: enter }, { x, y: enter }, { x, y: leave }, { x: a.x, y: leave });
|
|
217
|
+
}
|
|
218
|
+
edge.route.via = via;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// How far a layout's width/height ratio sits outside the accepted band, as a factor ≥ 1; 1 means inside the band.
|
|
224
|
+
export function aspectExcess(graph, routes) {
|
|
225
|
+
if (getDiagram(diagramTypeOf(graph)).sequence) return 1;
|
|
226
|
+
const bounds = graphBounds(graph, routes);
|
|
227
|
+
return ratioExcess(bounds.width / Math.max(1, bounds.height));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Layers of a layered ELK result, read back from the geometry: nodes whose extents overlap along the layer axis share a layer.
|
|
231
|
+
function layersOf(graph, down) {
|
|
232
|
+
const along = down ? 'y' : 'x', extent = down ? 'height' : 'width', across = down ? 'x' : 'y';
|
|
233
|
+
const layers = [];
|
|
234
|
+
for (const node of [...graph.nodes].sort((a, b) => a.position[along] - b.position[along] || a.id.localeCompare(b.id))) {
|
|
235
|
+
const current = layers.at(-1);
|
|
236
|
+
if (current && node.position[along] < current.end) { current.nodes.push(node); current.end = Math.max(current.end, node.position[along] + node.size[extent]); }
|
|
237
|
+
else layers.push({ nodes: [node], end: node.position[along] + node.size[extent] });
|
|
238
|
+
}
|
|
239
|
+
for (const layer of layers) layer.nodes.sort((a, b) => a.position[across] - b.position[across] || a.id.localeCompare(b.id));
|
|
240
|
+
return layers.map(layer => layer.nodes);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Layer indices where the set of top-level boundaries changes; rows prefer to break there.
|
|
244
|
+
function groupBoundaries(layers, graph) {
|
|
245
|
+
const topGroup = node => { let id = node.groupId; const groups = graph.groups ?? []; for (;;) { const parent = groups.find(group => group.id === id)?.parentId; if (!parent) return id ?? null; id = parent; } };
|
|
246
|
+
const signature = layer => [...new Set(layer.map(topGroup))].sort().join('|');
|
|
247
|
+
return new Set(layers.slice(1).map((layer, i) => signature(layer) !== signature(layers[i]) ? i + 1 : null).filter(Boolean));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Cut positions for `count` segments: even splits of the layer sequence, snapped to a top-level group boundary when one lies
|
|
251
|
+
// next to them and moved off a decision layer's branches so branch corridors stay together.
|
|
252
|
+
function segmentBreaks(layers, count, graph) {
|
|
253
|
+
const boundaries = groupBoundaries(layers, graph), breaks = [];
|
|
254
|
+
const avoid = new Set(layers.flatMap((layer, i) => layer.some(node => ['decision', 'choice'].includes(node.kind)) ? [i + 1] : []));
|
|
255
|
+
for (let segment = 1; segment < count; segment++) {
|
|
256
|
+
const even = Math.round(segment * layers.length / count);
|
|
257
|
+
const usable = position => position > (breaks.at(-1) ?? 0) && position < layers.length && !avoid.has(position);
|
|
258
|
+
const position = [even, even - 1, even + 1].find(item => boundaries.has(item) && usable(item)) ?? [even, even + 1, even - 1].find(usable);
|
|
259
|
+
if (position !== undefined) breaks.push(position);
|
|
260
|
+
}
|
|
261
|
+
return breaks;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A state chart folds only as a plain chain: apart from self transitions every state has at most one incoming and one
|
|
265
|
+
// outgoing transition, so no branch or loop has to cross a cut.
|
|
266
|
+
function stateChain(graph) {
|
|
267
|
+
const edges = graph.edges.filter(edge => edge.source !== edge.target);
|
|
268
|
+
return graph.nodes.every(node => edges.filter(edge => edge.source === node.id).length <= 1 && edges.filter(edge => edge.target === node.id).length <= 1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Fold a layered result by cutting its layer sequence into segments of consecutive layers placed side by side with aligned
|
|
272
|
+
// starts: a top-down layout becomes columns that each keep reading downward, a left-to-right layout becomes rows that each
|
|
273
|
+
// keep reading rightward. Geometry and ELK routes inside a segment are kept. An edge across a cut runs through the channel
|
|
274
|
+
// between the segments, or through the corridors before and after all segments when a neighbour stands in its way.
|
|
275
|
+
// Boundaries whose members end up in several segments are rebuilt around them and must not cover foreign nodes.
|
|
276
|
+
function foldSegments(graph, layers, breaks, { spacing, portGap, laneOrder }, down) {
|
|
277
|
+
const [M, C, mExtent, cExtent] = down ? ['y', 'x', 'height', 'width'] : ['x', 'y', 'width', 'height'];
|
|
278
|
+
const output = structuredClone(graph), type = diagramTypeOf(graph), byId = new Map(output.nodes.map(node => [node.id, node]));
|
|
279
|
+
const routes = createEdgeRoutes(graph), ranges = [0, ...breaks, layers.length];
|
|
280
|
+
const segments = ranges.slice(1).map((end, s) => layers.slice(ranges[s], end).flat().map(node => node.id));
|
|
281
|
+
const segmentOf = new Map(segments.flatMap((ids, s) => ids.map(id => [id, s])));
|
|
282
|
+
const groups = output.groups ?? [], errors = [];
|
|
283
|
+
const subtree = id => { const ids = new Set([id]); for (const group of stable(groups)) if (ids.has(group.parentId)) ids.add(group.id); return ids; };
|
|
284
|
+
const members = new Map(groups.map(group => [group.id, output.nodes.filter(node => subtree(group.id).has(node.groupId))]));
|
|
285
|
+
const spread = new Map(groups.map(group => [group.id, new Set(members.get(group.id).map(node => segmentOf.get(node.id)))]));
|
|
286
|
+
const internal = edge => segmentOf.get(edge.source) === segmentOf.get(edge.target);
|
|
287
|
+
const start = (item, axis) => item.position[axis], end = (item, axis, extent) => item.position[axis] + item.size[extent];
|
|
288
|
+
// A segment's extent covers its nodes, the boundaries that stay inside it and the routes and labels of its inner edges.
|
|
289
|
+
const extents = segments.map((ids, s) => {
|
|
290
|
+
const boxes = [...ids.map(id => box(byId.get(id))), ...groups.filter(group => spread.get(group.id).size === 1 && spread.get(group.id).has(s) && group.position && group.size).map(box)];
|
|
291
|
+
for (const edge of output.edges.filter(edge => internal(edge) && segmentOf.get(edge.source) === s)) {
|
|
292
|
+
const route = routes.get(edge.id);
|
|
293
|
+
boxes.push(...route.points.map(point => ({ ...point, width: 0, height: 0 })));
|
|
294
|
+
if (route.label) boxes.push(route.labelBox);
|
|
295
|
+
}
|
|
296
|
+
const x = Math.min(...boxes.map(item => item.x)), y = Math.min(...boxes.map(item => item.y));
|
|
297
|
+
return { x, y, width: Math.max(...boxes.map(item => item.x + item.width)) - x, height: Math.max(...boxes.map(item => item.y + item.height)) - y };
|
|
298
|
+
});
|
|
299
|
+
// A boundary spread over several segments is rebuilt around its members further down; reserve its heading room before its
|
|
300
|
+
// first segment and its inset after its last one now, so the channel between them stays clear of its border.
|
|
301
|
+
const room = group => groupHeadingLayout({ ...group, size: undefined }).height + LAYOUT_LIMITS.groupHeadingGap
|
|
302
|
+
+ Math.max(0, ...groups.filter(child => child.parentId === group.id && spread.get(child.id).size > 1).map(room));
|
|
303
|
+
for (const group of groups.filter(group => spread.get(group.id).size > 1)) {
|
|
304
|
+
const first = extents[Math.min(...spread.get(group.id))], last = extents[Math.max(...spread.get(group.id))];
|
|
305
|
+
first[C] -= room(group); first[cExtent] += room(group); last[cExtent] += LAYOUT_LIMITS.groupInset;
|
|
306
|
+
}
|
|
307
|
+
// Crossing edges take the channel right after the earlier of their two segments; lanes there are spaced for their labels.
|
|
308
|
+
const crossing = laneOrder ? output.edges.filter(edge => !internal(edge)).sort((a, b) => laneOrder.indexOf(a.id) - laneOrder.indexOf(b.id)) : stable(output.edges.filter(edge => !internal(edge)));
|
|
309
|
+
const gapOf = edge => Math.min(segmentOf.get(edge.source), segmentOf.get(edge.target));
|
|
310
|
+
const lanes = segments.slice(1).map(() => []), labelSpan = segments.slice(1).map(() => 0);
|
|
311
|
+
for (const edge of crossing) { lanes[gapOf(edge)].push(edge.id); labelSpan[gapOf(edge)] = Math.max(labelSpan[gapOf(edge)], estimateLabelSize(visibleEdgeLabel(edge, type, graph.meta.locale))[cExtent]); }
|
|
312
|
+
// A channel keeps label clearance from both segments and leaves every endpoint its straight stub.
|
|
313
|
+
const margin = Math.max(LAYOUT_LIMITS.labelGap, (getDiagram(type).endpointStub ?? LAYOUT_LIMITS.endpoint) + LAYOUT_LIMITS.labelEdgeGap);
|
|
314
|
+
const step = g => LAYOUT_LIMITS.parallelGap + labelSpan[g];
|
|
315
|
+
const gapWidth = g => Math.max(spacing, 2 * margin + (lanes[g].length - 1) * step(g) + labelSpan[g]);
|
|
316
|
+
const placed = segments.reduce((acc, ids, s) => [...acc, s ? acc[s - 1] + extents[s - 1][cExtent] + gapWidth(s - 1) : 0], []);
|
|
317
|
+
const delta = segments.map((ids, s) => ({ [C]: placed[s] - extents[s][C], [M]: -extents[s][M] }));
|
|
318
|
+
const shift = (point, d) => ({ x: round(point.x + d.x), y: round(point.y + d.y) });
|
|
319
|
+
for (const node of output.nodes) node.position = shift(node.position, delta[segmentOf.get(node.id)]);
|
|
320
|
+
for (const edge of output.edges) {
|
|
321
|
+
if (!internal(edge)) { delete edge.route; continue; }
|
|
322
|
+
const d = delta[segmentOf.get(edge.source)];
|
|
323
|
+
if (edge.route?.via) edge.route.via = edge.route.via.map(point => shift(point, d));
|
|
324
|
+
if (edge.route?.labelAt) edge.route.labelAt = shift(edge.route.labelAt, d);
|
|
325
|
+
}
|
|
326
|
+
// A boundary inside one segment moves with it; a boundary spread over several is rebuilt around its members and children.
|
|
327
|
+
const inset = LAYOUT_LIMITS.groupInset, done = new Set();
|
|
328
|
+
const place = group => {
|
|
329
|
+
if (done.has(group.id)) return; done.add(group.id);
|
|
330
|
+
const children = groups.filter(child => child.parentId === group.id); children.forEach(place);
|
|
331
|
+
if (spread.get(group.id).size <= 1) { if (group.position) group.position = shift(group.position, delta[[...spread.get(group.id)][0] ?? 0]); return; }
|
|
332
|
+
const heading = groupHeadingLayout({ ...group, size: undefined }), boxes = [...members.get(group.id).map(box), ...children.filter(child => child.position && child.size).map(box)];
|
|
333
|
+
const x = Math.min(...boxes.map(item => item.x)) - inset, y = Math.min(...boxes.map(item => item.y)) - heading.height - LAYOUT_LIMITS.groupHeadingGap;
|
|
334
|
+
const width = Math.max(heading.width + 64, Math.max(...boxes.map(item => item.x + item.width)) + inset - x), height = Math.max(...boxes.map(item => item.y + item.height)) + inset - y;
|
|
335
|
+
group.position = { x: round(x), y: round(y) }; group.size = { width: round(width), height: round(height) };
|
|
336
|
+
};
|
|
337
|
+
stable(groups).forEach(place);
|
|
338
|
+
for (const group of groups.filter(group => spread.get(group.id).size > 1)) {
|
|
339
|
+
for (const node of output.nodes) if (!members.get(group.id).includes(node) && intersects(box(group), box(node))) errors.push(`fold: boundary ${group.id} would cover ${node.id}`);
|
|
340
|
+
for (const other of groups) if (other.id !== group.id && (other.parentId ?? null) === (group.parentId ?? null) && intersects(box(group), box(other))) errors.push(`fold: boundaries ${group.id} and ${other.id} would overlap`);
|
|
341
|
+
}
|
|
342
|
+
if (errors.length) return { graph: output, errors };
|
|
343
|
+
// A crossing edge leaves and enters sideways straight into the channel when neither a neighbour nor a boundary heading
|
|
344
|
+
// stands in the way, else through the corridor before or after all segments; decisions always leave sideways so their
|
|
345
|
+
// branch corridors stay apart.
|
|
346
|
+
const far = Math.max(...extents.map(extent => extent[mExtent])), corridor = 2 * LAYOUT_LIMITS.labelGap;
|
|
347
|
+
const headings = groups.filter(group => group.position && group.size).flatMap(group => groupHeadingBoxes(group));
|
|
348
|
+
const overlaps = (a, b, axis, extent) => start(a, axis) < end(b, axis, extent) && start(b, axis) < end(a, axis, extent);
|
|
349
|
+
const neighbours = (node, axis, after) => segments[segmentOf.get(node.id)].map(id => byId.get(id))
|
|
350
|
+
.filter(other => other !== node && overlaps(other, node, axis === M ? C : M, axis === M ? cExtent : mExtent) && (after ? start(other, axis) > start(node, axis) : start(other, axis) < start(node, axis)));
|
|
351
|
+
const open = (a, b) => !headings.some(heading => segmentCrossesBox(a, b, heading));
|
|
352
|
+
// Several crossing edges at one node spread their anchors along its side so no two share an initial segment.
|
|
353
|
+
const shared = new Map(output.nodes.map(node => [node.id, crossing.filter(edge => edge.source === node.id || edge.target === node.id).map(edge => edge.id)]));
|
|
354
|
+
const lane = (node, edge) => (shared.get(node.id).indexOf(edge.id) - (shared.get(node.id).length - 1) / 2) * LAYOUT_LIMITS.parallelGap;
|
|
355
|
+
const point = (c, m) => ({ [C]: c, [M]: m });
|
|
356
|
+
crossing.forEach((edge, i) => {
|
|
357
|
+
const source = byId.get(edge.source), target = byId.get(edge.target), g = gapOf(edge), forward = segmentOf.get(edge.target) > segmentOf.get(edge.source);
|
|
358
|
+
const centre = (node, axis, extent) => start(node, axis) + node.size[extent] / 2 + lane(node, edge);
|
|
359
|
+
const channel = placed[g] + extents[g][cExtent] + margin + labelSpan[g] / 2 + lanes[g].indexOf(edge.id) * step(g);
|
|
360
|
+
// The corridor after a segment hugs that segment when the channel is next to it; a run past other segments uses the far end.
|
|
361
|
+
const adjacent = Math.abs(segmentOf.get(edge.target) - segmentOf.get(edge.source)) === 1;
|
|
362
|
+
const corridorAt = (where, node) => where === 'after' ? (adjacent ? extents[segmentOf.get(node.id)][mExtent] : far) + corridor + i * LAYOUT_LIMITS.parallelGap : -corridor - i * LAYOUT_LIMITS.parallelGap;
|
|
363
|
+
// The straight run between a node and its first waypoint must meet no neighbour and cross no heading.
|
|
364
|
+
const sideRun = (node, towards) => open(point(towards ? end(node, C, cExtent) : start(node, C), centre(node, M, mExtent)), point(channel, centre(node, M, mExtent)));
|
|
365
|
+
const endRun = (node, where) => open(point(centre(node, C, cExtent), where === 'after' ? end(node, M, mExtent) : start(node, M)), point(centre(node, C, cExtent), corridorAt(where, node)));
|
|
366
|
+
const passage = (node, towards, sideways) => sideways && sideRun(node, towards) ? 'side'
|
|
367
|
+
: !neighbours(node, M, true).length && endRun(node, 'after') ? 'after' : !neighbours(node, M, false).length && endRun(node, 'before') ? 'before' : 'side';
|
|
368
|
+
const exit = passage(source, forward, ['decision', 'choice'].includes(source.kind) || !neighbours(source, C, forward).length);
|
|
369
|
+
const entry = passage(target, !forward, !neighbours(target, C, !forward).length);
|
|
370
|
+
const via = exit === 'side' ? [point(channel, centre(source, M, mExtent))] : [point(centre(source, C, cExtent), corridorAt(exit, source)), point(channel, corridorAt(exit, source))];
|
|
371
|
+
via.push(...(entry === 'side' ? [point(channel, centre(target, M, mExtent))] : [point(channel, corridorAt(entry, target)), point(centre(target, C, cExtent), corridorAt(entry, target))]));
|
|
372
|
+
const from = via[exit === 'side' ? 0 : 1][M], to = via[exit === 'side' ? 1 : 2][M];
|
|
373
|
+
edge.route = { via: via.map(item => ({ x: round(item.x), y: round(item.y) })), ...(visibleEdgeLabel(edge, type, graph.meta.locale) && from !== to ? { labelAt: { x: round(point(channel, (from + to) / 2).x), y: round(point(channel, (from + to) / 2).y) } } : {}) };
|
|
374
|
+
});
|
|
375
|
+
// Keep the ELK canvas padding around nodes, boundaries and the new corridors.
|
|
376
|
+
const everything = [...output.nodes.map(box), ...groups.filter(group => group.position && group.size).map(box), ...output.edges.flatMap(edge => (edge.route?.via ?? []).map(item => ({ ...item, width: 0, height: 0 })))];
|
|
377
|
+
const offset = { x: 32 - Math.min(...everything.map(item => item.x)), y: 32 - Math.min(...everything.map(item => item.y)) };
|
|
378
|
+
for (const item of [...output.nodes, ...groups.filter(group => group.position && group.size)]) item.position = shift(item.position, offset);
|
|
379
|
+
for (const edge of output.edges) {
|
|
380
|
+
if (edge.route?.via) edge.route.via = edge.route.via.map(item => shift(item, offset));
|
|
381
|
+
if (edge.route?.labelAt) edge.route.labelAt = shift(edge.route.labelAt, offset);
|
|
382
|
+
}
|
|
383
|
+
if (groups.length) bypassHeadings(output, portGap);
|
|
384
|
+
return { graph: output, errors };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function foldedVariants(graph, options) {
|
|
388
|
+
const type = diagramTypeOf(graph);
|
|
389
|
+
if (graph.nodes.some(node => node.layout?.rank !== undefined) || type === 'state' && !stateChain(graph)) return [];
|
|
390
|
+
const down = !['er', 'deployment', 'dataflow', 'usecase'].includes(type), bounds = graphBounds(graph);
|
|
391
|
+
// Cutting shortens the layer sequence: a top-down layout folds only when too tall, a left-to-right one only when too wide.
|
|
392
|
+
if (down ? bounds.width >= bounds.height : bounds.height >= bounds.width) return [];
|
|
393
|
+
const layers = layersOf(graph, down);
|
|
394
|
+
if (layers.length < 3) return [];
|
|
395
|
+
const variants = [];
|
|
396
|
+
for (let count = 2; count <= Math.min(FOLD_MAX, layers.length); count++) {
|
|
397
|
+
const breaks = segmentBreaks(layers, count, graph);
|
|
398
|
+
if (breaks.length !== count - 1 || variants.some(variant => variant.breaks.join() === breaks.join())) continue;
|
|
399
|
+
variants.push({ axis: down ? 'columns' : 'rows', count, breaks, ...foldSegments(graph, layers, breaks, options, down) });
|
|
400
|
+
}
|
|
401
|
+
return variants;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function candidateScore(graph, audit, index) {
|
|
405
|
+
const bounds = graphBounds(graph, audit.routes), budget = canvasBudgetFor(diagramTypeOf(graph));
|
|
406
|
+
const routes = [...audit.routes.values()];
|
|
407
|
+
const length = routes.reduce((sum, route) => sum + route.points.slice(1).reduce((sum, point, i) => sum + Math.hypot(point.x - route.points[i].x, point.y - route.points[i].y), 0), 0);
|
|
408
|
+
const area = graph.nodes.reduce((sum, node) => { const box = occupiedBox(node, diagramTypeOf(graph)); return sum + box.width * box.height; }, 0);
|
|
409
|
+
const count = Math.max(1, routes.length), unit = Math.sqrt(area / graph.nodes.length);
|
|
410
|
+
const crossings = audit.crossings.reduce((sum, item) => sum + item.measured + 2 * item.repeated, 0);
|
|
411
|
+
const bends = routes.reduce((sum, route) => sum + route.points.length - 2, 0);
|
|
412
|
+
// Normalize by content, so a small routing improvement cannot justify unlimited whitespace.
|
|
413
|
+
const cost = bounds.width * bounds.height / area + length / (count * unit) + .25 * bends / count + 4 * crossings / count;
|
|
414
|
+
// Shapes of one rank compete on crossings, then on the exact distance from the band (every in-band shape scores 1), then
|
|
415
|
+
// on compactness; a better rank wins first. The type's budget ratio only breaks ties towards its preferred orientation.
|
|
416
|
+
const excess = +aspectExcess(graph, audit.routes).toFixed(2);
|
|
417
|
+
return [audit.errors.length, shapeRank(excess), crossings, excess, round(cost), budget ? Math.abs(bounds.width / bounds.height - budget.width / budget.height) : 0, index];
|
|
418
|
+
}
|
|
419
|
+
const compare = (a, b) => { for (let i = 0; i < a.score.length; i++) if (a.score[i] !== b.score[i]) return a.score[i] - b.score[i]; return 0; };
|
|
420
|
+
|
|
421
|
+
export async function compileGraphLayout(input, { layout = 'auto', timeoutMs = LAYOUT_TIMEOUT_MS } = {}) {
|
|
422
|
+
if (!['auto', 'preserve'].includes(layout)) throw new Error('layout must be auto or preserve');
|
|
423
|
+
const errors = validateGraph(input, { inputOnly: true });
|
|
424
|
+
if (errors.length) throw qualityFailure(input, 'semantic', `Invalid semantic input:\n- ${errors.join('\n- ')}`);
|
|
425
|
+
const graph = structuredClone(input);
|
|
426
|
+
let migration;
|
|
427
|
+
try { migration = migrateOwnership(graph); }
|
|
428
|
+
catch (error) { throw qualityFailure(graph, 'semantic', error.message); }
|
|
429
|
+
const semanticInput = semanticDigest(input);
|
|
430
|
+
const semanticReport = output => {
|
|
431
|
+
const semanticOutput = semanticDigest(output, migration);
|
|
432
|
+
if (semanticInput !== semanticOutput) throw new Error('Layout changed semantic facts; refusing output');
|
|
433
|
+
return { inputSha256: semanticInput, outputSha256: semanticOutput, preserved: true };
|
|
434
|
+
};
|
|
435
|
+
const ownershipErrors = validateGraph(graph, { inputOnly: true });
|
|
436
|
+
if (ownershipErrors.length) throw qualityFailure(graph, 'semantic', ownershipErrors.join('\n'));
|
|
437
|
+
if (layout === 'preserve') {
|
|
438
|
+
requireDiagramQuality(graph);
|
|
439
|
+
return { graph, report: { version: LAYOUT_VERSION, mode: layout, migration, semantics: semanticReport(graph) } };
|
|
440
|
+
}
|
|
441
|
+
const sequence = getDiagram(diagramTypeOf(graph)).sequence;
|
|
442
|
+
const worker = new Worker(new URL('../assets/layout-dist/worker.mjs', import.meta.url), { execArgv: [] });
|
|
443
|
+
let pending, expired = false;
|
|
444
|
+
const fail = error => pending?.reject(error);
|
|
445
|
+
worker.on('error', fail);
|
|
446
|
+
worker.on('exit', code => { if (code !== 0) fail(new Error(`Layout worker exited (${code})`)); });
|
|
447
|
+
worker.on('message', message => message.error ? fail(new Error(message.error)) : pending?.resolve(message.graph));
|
|
448
|
+
const timeout = Math.min(LAYOUT_TIMEOUT_MS, Math.max(1, timeoutMs));
|
|
449
|
+
const candidates = [];
|
|
450
|
+
try {
|
|
451
|
+
const extras = new WeakMap();
|
|
452
|
+
// Each solve has its own limit; the compile as a whole has none, so load can slow it but never fail it or change its result.
|
|
453
|
+
const post = root => new Promise((resolve, reject) => {
|
|
454
|
+
const timer = setTimeout(() => { expired = true; fail(new Error(`Layout exceeded ${timeout}ms for ${diagramTypeOf(graph)}`)); worker.terminate(); }, timeout);
|
|
455
|
+
pending = { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } };
|
|
456
|
+
worker.postMessage(root);
|
|
457
|
+
});
|
|
458
|
+
const evaluate = (candidate, index, errors = []) => {
|
|
459
|
+
let audit = auditLayoutQuality(candidate);
|
|
460
|
+
if (errors.length) audit = { ...audit, errors: [...errors, ...audit.errors] };
|
|
461
|
+
// ELK can put a label at a legal point crossing. Slide only that label along its own nearest segment.
|
|
462
|
+
if (!sequence) for (const id of [...new Set(audit.diagnostics.filter(item => item.ruleId === 'spacing.label-edge').map(item => item.elementIds[0]))].sort()) {
|
|
463
|
+
const edge = candidate.edges.find(item => item.id === id), route = audit.routes.get(id), origin = edge.route?.labelAt;
|
|
464
|
+
if (!origin) continue;
|
|
465
|
+
const segment = route.points.slice(1).map((b, i) => {
|
|
466
|
+
const a = route.points[i], x = Math.max(Math.min(a.x, b.x), Math.min(Math.max(a.x, b.x), origin.x)), y = Math.max(Math.min(a.y, b.y), Math.min(Math.max(a.y, b.y), origin.y));
|
|
467
|
+
return { a, b, distance: Math.hypot(x - origin.x, y - origin.y) };
|
|
468
|
+
}).sort((a, b) => a.distance - b.distance)[0];
|
|
469
|
+
const axis = segment.a.x === segment.b.x ? 'y' : 'x', step = (axis === 'y' ? route.labelBox.height : route.labelBox.width) + 24;
|
|
470
|
+
let best = origin;
|
|
471
|
+
for (const offset of [-1, 1, -2, 2]) {
|
|
472
|
+
const point = { ...origin, [axis]: round(origin[axis] + step * offset) };
|
|
473
|
+
if (point[axis] < Math.min(segment.a[axis], segment.b[axis]) + step / 2 || point[axis] > Math.max(segment.a[axis], segment.b[axis]) - step / 2) continue;
|
|
474
|
+
edge.route.labelAt = point;
|
|
475
|
+
const checked = auditLayoutQuality(candidate);
|
|
476
|
+
if (checked.errors.length < audit.errors.length) { best = point; audit = checked; }
|
|
477
|
+
}
|
|
478
|
+
edge.route.labelAt = best;
|
|
479
|
+
}
|
|
480
|
+
return { graph: candidate, errors: audit.errors, diagnostics: audit.diagnostics, score: candidateScore(candidate, audit, index), crossings: audit.crossings, excess: +aspectExcess(candidate, audit.routes).toFixed(2) };
|
|
481
|
+
};
|
|
482
|
+
const finish = (index, layered, prepared, hints) => {
|
|
483
|
+
const unfolded = { ...evaluate(layered, index), fold: 0, axis: null };
|
|
484
|
+
// Fold only a shape beyond the band's slack; the unfolded result stays available as the fallback.
|
|
485
|
+
const variants = !sequence && unfolded.excess > ASPECT_SLACK
|
|
486
|
+
? foldedVariants(layered, { spacing: LAYOUT_TARGETS.layerGap + [0, 16, 48][index % 3], stub: prepared.stub, portGap: prepared.portGap, laneOrder: hints.lanes }).map(variant => ({ ...evaluate(variant.graph, index, variant.errors), fold: variant.count, axis: variant.axis })) : [];
|
|
487
|
+
// Folding exists to fix the shape: among the folds the quality gate accepts within the fold slack of the band, the one
|
|
488
|
+
// with the fewest crossings wins, then the fewest segments; otherwise the nearest valid shape competes with the
|
|
489
|
+
// unfolded result.
|
|
490
|
+
const valid = variants.filter(variant => !variant.errors.length), within = valid.filter(variant => variant.excess <= ASPECT_SLACK);
|
|
491
|
+
const fold = within.length ? within.reduce((a, b) => b.score[2] < a.score[2] ? b : a) : valid.sort((a, b) => a.excess - b.excess || compare(a, b))[0];
|
|
492
|
+
const chosen = fold && compare(fold, unfolded) < 0 ? fold : unfolded;
|
|
493
|
+
const candidate = { index, ...chosen, folds: variants.map(variant => ({ axis: variant.axis, count: variant.fold, errors: variant.errors, excess: variant.excess, score: variant.score })) };
|
|
494
|
+
extras.set(candidate, { layered, prepared, hints });
|
|
495
|
+
return candidate;
|
|
496
|
+
};
|
|
497
|
+
const solve = async (index, hints = {}) => {
|
|
498
|
+
const prepared = sequence ? null : elkInput(graph, index, hints);
|
|
499
|
+
return finish(index, sequence ? compileSequence(graph, index) : applyElk(graph, await post(prepared.root), prepared), prepared, hints);
|
|
500
|
+
};
|
|
501
|
+
// Crossing-directed local search over port order, decision branch sides and fold lanes (see REFINE_EVALUATIONS).
|
|
502
|
+
const swapped = (list, i, j) => { const next = [...list]; [next[i], next[j]] = [next[j], next[i]]; return next; };
|
|
503
|
+
const refine = async base => {
|
|
504
|
+
let best = base, evaluations = 0;
|
|
505
|
+
const first = extras.get(base);
|
|
506
|
+
let hints = { ports: new Map(first.prepared.buckets), lanes: stable(graph.edges).map(edge => edge.id), sides: new Map(), nodes: graph.nodes.some(node => node.layout?.order !== undefined) ? undefined : stable(graph.nodes).map(node => node.id) };
|
|
507
|
+
if (['flowchart', 'state'].includes(diagramTypeOf(graph))) for (const node of stable(graph.nodes.filter(node => ['decision', 'choice'].includes(node.kind)))) {
|
|
508
|
+
const branches = stable(graph.edges.filter(edge => edge.source === node.id && edge.target !== node.id)).map(edge => edge.id);
|
|
509
|
+
if (branches.length >= 2) hints.sides.set(node.id, branches);
|
|
510
|
+
}
|
|
511
|
+
if (diagramTypeOf(graph) === 'usecase') for (const node of stable(graph.nodes.filter(node => node.kind === 'actor'))) {
|
|
512
|
+
const incident = stable(graph.edges.filter(edge => edge.source === node.id || edge.target === node.id)).map(edge => edge.id);
|
|
513
|
+
if (incident.length >= 2) hints.sides.set(node.id, incident);
|
|
514
|
+
}
|
|
515
|
+
while (best.score[2] && evaluations < REFINE_EVALUATIONS) {
|
|
516
|
+
const here = extras.get(best), crossing = [...new Set(best.crossings.flatMap(item => item.elementIds))].sort();
|
|
517
|
+
const moves = [];
|
|
518
|
+
for (const id of crossing) {
|
|
519
|
+
for (const [portId, role] of here.prepared.portRoles) {
|
|
520
|
+
if (role.edge.id !== id) continue;
|
|
521
|
+
const key = `n:${role.edge[role.role]}:${role.side}`, list = hints.ports.get(key) ?? [], at = list.indexOf(portId);
|
|
522
|
+
for (let other = 0; other < list.length; other++) if (other !== at) moves.push({ rerun: true, hints: { ...hints, ports: new Map(hints.ports).set(key, swapped(list, at, other)) } });
|
|
523
|
+
}
|
|
524
|
+
for (const [node, branches] of hints.sides) {
|
|
525
|
+
const at = branches.indexOf(id);
|
|
526
|
+
if (at >= 0) for (let other = 0; other < branches.length; other++) if (other !== at) moves.push({ rerun: true, hints: { ...hints, sides: new Map(hints.sides).set(node, swapped(branches, at, other)) } });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (hints.nodes) {
|
|
530
|
+
const ends = [...new Set(graph.edges.filter(edge => crossing.includes(edge.id)).flatMap(edge => [edge.source, edge.target]))].sort();
|
|
531
|
+
for (const a of ends) for (const b of ends) if (a < b && graph.nodes.find(node => node.id === a).groupId === graph.nodes.find(node => node.id === b).groupId) moves.push({ rerun: true, hints: { ...hints, nodes: swapped(hints.nodes, hints.nodes.indexOf(a), hints.nodes.indexOf(b)) } });
|
|
532
|
+
}
|
|
533
|
+
if (best.fold) for (const a of crossing) for (const b of crossing) if (a < b) moves.push({ rerun: false, hints: { ...hints, lanes: swapped(hints.lanes, hints.lanes.indexOf(a), hints.lanes.indexOf(b)) } });
|
|
534
|
+
let improved = false;
|
|
535
|
+
for (const move of moves) {
|
|
536
|
+
if (evaluations >= REFINE_EVALUATIONS) break;
|
|
537
|
+
evaluations++;
|
|
538
|
+
let next;
|
|
539
|
+
try { next = move.rerun ? await solve(base.index, move.hints) : finish(base.index, here.layered, here.prepared, move.hints); }
|
|
540
|
+
catch (error) { if (expired) throw error; continue; }
|
|
541
|
+
if (compare(next, best) < 0) { best = Object.assign(next, { refined: true }); hints = move.hints; improved = true; break; }
|
|
542
|
+
}
|
|
543
|
+
if (!improved) break;
|
|
544
|
+
}
|
|
545
|
+
return best;
|
|
546
|
+
};
|
|
547
|
+
for (let index = 0; index < CANDIDATE_COUNT; index++) {
|
|
548
|
+
try { candidates.push(await solve(index)); }
|
|
549
|
+
catch (error) {
|
|
550
|
+
if (expired) throw error;
|
|
551
|
+
candidates.push({ index, errors: [error.message], diagnostics: qualityFailure(graph, 'geometry', error.message).diagnostics, score: [Infinity, 0, 0, 0, 0, 0, 0, 0, index] });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
candidates.sort(compare);
|
|
555
|
+
if (!sequence) for (const base of candidates.filter(candidate => !candidate.errors.length && candidate.score[2] > 0).slice(0, REFINE_CANDIDATES)) {
|
|
556
|
+
candidates[candidates.indexOf(base)] = await refine(base);
|
|
557
|
+
}
|
|
558
|
+
candidates.sort(compare);
|
|
559
|
+
const best = candidates[0];
|
|
560
|
+
if (best.errors.length) throw Object.assign(qualityFailure(graph, 'geometry', `No valid layout candidate for ${diagramTypeOf(graph)}:\n- ${best.errors.join('\n- ')}`, best.diagnostics), { diagnosticGraph: best.graph, candidates: candidates.map(({ graph, ...item }) => item) });
|
|
561
|
+
best.graph.layout = { ...best.graph.layout, version: LAYOUT_VERSION, strategy: `${sequence ? 'sequence' : 'layered'}-${best.index}${best.fold ? `-fold${best.fold}${best.axis === 'columns' ? 'c' : ''}` : ''}` };
|
|
562
|
+
return { graph: best.graph, report: { version: LAYOUT_VERSION, mode: layout, candidateCount: CANDIDATE_COUNT, timeoutMs: timeout, selected: best.index, migration, semantics: semanticReport(best.graph), candidates: candidates.map(({ graph, ...item }) => item) } };
|
|
563
|
+
} catch (error) { throw error.phases ? error : qualityFailure(graph, 'geometry', error.message); }
|
|
564
|
+
finally { await worker.terminate(); }
|
|
565
|
+
}
|