qgraphflow 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/README.md +119 -70
- package/docs/clients.de.md +15 -24
- package/docs/clients.es.md +15 -24
- package/docs/clients.ja.md +15 -24
- package/docs/clients.md +15 -24
- package/docs/clients.pt.md +15 -24
- package/docs/clients.ru.md +15 -24
- package/docs/clients.zh-CN.md +15 -24
- package/docs/readme/README.de.md +120 -71
- package/docs/readme/README.es.md +120 -71
- package/docs/readme/README.ja.md +120 -71
- package/docs/readme/README.pt.md +120 -71
- package/docs/readme/README.ru.md +120 -71
- package/docs/readme/README.zh-CN.md +106 -59
- package/examples/jeepay/README.md +23 -0
- package/examples/jeepay/capabilities.graph.json +270 -0
- package/examples/jeepay/class.graph.json +237 -0
- package/examples/jeepay/collection.graph.json +3057 -0
- package/examples/jeepay/dataflow.graph.json +212 -0
- package/examples/jeepay/deployment.graph.json +222 -0
- package/examples/jeepay/engineering.graph.json +277 -0
- package/examples/jeepay/er.graph.json +482 -0
- package/examples/jeepay/flowchart.graph.json +312 -0
- package/examples/jeepay/relations.graph.json +289 -0
- package/examples/jeepay/sequence.graph.json +355 -0
- package/examples/jeepay/source.json +95 -0
- package/examples/jeepay/state.graph.json +175 -0
- package/examples/jeepay/usecase.graph.json +222 -0
- package/package.json +14 -3
- package/skills/q-flow/SKILL.md +28 -20
- package/skills/q-flow/agents/openai.yaml +1 -1
- package/skills/q-flow/assets/viewer/package.json +1 -1
- package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
- package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
- package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
- package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
- package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
- package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
- package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
- package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
- package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
- package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
- package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
- package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
- package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
- package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
- package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
- package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
- package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
- package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
- package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
- package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
- package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
- package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
- package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
- package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
- package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
- package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
- package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
- package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
- package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
- package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
- package/skills/q-flow/assets/viewer-dist/index.html +30 -28
- package/skills/q-flow/references/evidence-sources.md +7 -5
- package/skills/q-flow/references/graph-common.md +34 -34
- package/skills/q-flow/references/graph-schema.md +28 -7
- package/skills/q-flow/references/guided-intake.md +51 -71
- package/skills/q-flow/references/layout-routing.md +47 -0
- package/skills/q-flow/references/types/architecture.md +42 -22
- package/skills/q-flow/references/types/class.md +9 -2
- package/skills/q-flow/references/types/dataflow.md +11 -4
- package/skills/q-flow/references/types/deployment.md +11 -3
- package/skills/q-flow/references/types/er.md +8 -1
- package/skills/q-flow/references/types/flowchart.md +12 -5
- package/skills/q-flow/references/types/sequence.md +20 -16
- package/skills/q-flow/references/types/state.md +10 -3
- package/skills/q-flow/references/types/usecase.md +6 -0
- package/skills/q-flow/references/viewer-development.md +37 -24
- package/skills/q-flow/references/visual-contract.md +12 -6
- package/skills/q-flow/scripts/compile-layout.mjs +85 -102
- package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
- package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
- package/skills/q-flow/scripts/validate-graph.mjs +38 -21
- package/examples/order-flow.graph.json +0 -94
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { affectedRouteIds } from './route-clearance.js';
|
|
2
|
+
import { compactCandidates } from './layout-compaction.js';
|
|
3
|
+
import { layoutWeights, LAYOUT_VERSION, currentLayout } from './layout-policy.js';
|
|
4
|
+
import { routeOrthogonal } from './orthogonal-routing.js';
|
|
5
|
+
import { auditLayoutQuality, requireDiagramQuality } from './layout-quality.js';
|
|
6
|
+
import { graphBounds, occupiedBox } from './edge-routing.js';
|
|
7
|
+
import { diagramTypeOf, getDiagram, canvasBudgetFor } from './diagrams/registry.js';
|
|
8
|
+
import { layoutTargets, layoutLimits } from './layout-spacing.js';
|
|
9
|
+
import { groupHeadingLayout } from './text-layout.js';
|
|
10
|
+
import { isArchitectureOverview } from './view-identity.js';
|
|
11
|
+
import { spreadParticipants } from './sequence-executions.js';
|
|
12
|
+
import { preservesTemplateOrder } from './layout-templates.js';
|
|
13
|
+
|
|
14
|
+
export const REFINE_BUDGET = 60;
|
|
15
|
+
export const MAX_LOCAL_SHIFT = 156;
|
|
16
|
+
const stable = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
17
|
+
|
|
18
|
+
export function layoutMetrics(graph, audit = auditLayoutQuality(graph)) {
|
|
19
|
+
const routes = [...audit.routes.values()], bounds = graphBounds(graph, audit.routes), count = Math.max(1, routes.length);
|
|
20
|
+
const content = graph.nodes.reduce((sum, n) => { const b = occupiedBox(n, diagramTypeOf(graph)); return sum + b.width * b.height; }, 0), unit = Math.sqrt(content / Math.max(1, graph.nodes.length));
|
|
21
|
+
const length = routes.reduce((sum, r) => sum + r.points.slice(1).reduce((s, p, i) => s + Math.abs(p.x - r.points[i].x) + Math.abs(p.y - r.points[i].y), 0), 0);
|
|
22
|
+
const bends = routes.reduce((sum, r) => sum + Math.max(0, r.points.length - 2), 0), crossings = audit.crossings.reduce((sum, item) => sum + item.measured, 0);
|
|
23
|
+
const canvas=canvasBudgetFor(diagramTypeOf(graph)),aspectRatio=bounds.width/Math.max(1,bounds.height),aspectPenalty=canvas?Math.abs(aspectRatio-canvas.width/canvas.height):0;
|
|
24
|
+
return { errors: audit.errors.length, aspectRatio, aspectPenalty, area: bounds.width * bounds.height, length, bends, crossings, cost: bounds.width * bounds.height / content + length / (count * unit) + layoutWeights.bends * bends / count + layoutWeights.crossings * crossings / count };
|
|
25
|
+
}
|
|
26
|
+
const better = (a, b) => a.errors < b.errors || a.errors === b.errors && (a.cost < b.cost - .001 || Math.abs(a.cost-b.cost)<.001 && a.aspectPenalty < b.aspectPenalty - .001);
|
|
27
|
+
|
|
28
|
+
function scopeIds(graph, focusId) {
|
|
29
|
+
if (!focusId) return new Set(graph.nodes.map(n => n.id));
|
|
30
|
+
return new Set([focusId, ...graph.edges.filter(e => e.source === focusId || e.target === focusId).flatMap(e => [e.source, e.target])]);
|
|
31
|
+
}
|
|
32
|
+
function preservesOrder(before, after) {
|
|
33
|
+
if (!preservesTemplateOrder(before, after)) return false;
|
|
34
|
+
const sequence = getDiagram(diagramTypeOf(before)).sequence;
|
|
35
|
+
const axis = sequence ? 'x' : before.layout?.direction === 'right' ? 'y' : 'x';
|
|
36
|
+
for (const a of before.nodes) for (const b of before.nodes) {
|
|
37
|
+
if (a.id >= b.id || a.groupId !== b.groupId || a.layout?.order === undefined || b.layout?.order === undefined) continue;
|
|
38
|
+
const na = after.nodes.find(n => n.id === a.id), nb = after.nodes.find(n => n.id === b.id);
|
|
39
|
+
if ((a.position[axis] - b.position[axis]) * (na.position[axis] - nb.position[axis]) < 0) return false;
|
|
40
|
+
}
|
|
41
|
+
if (['flowchart', 'state'].includes(diagramTypeOf(before))) for (const e of before.edges) {
|
|
42
|
+
const a = before.nodes.find(n => n.id === e.source), b = before.nodes.find(n => n.id === e.target);
|
|
43
|
+
if (a.id === b.id) continue;
|
|
44
|
+
const na = after.nodes.find(n => n.id === a.id), nb = after.nodes.find(n => n.id === b.id);
|
|
45
|
+
if (a.position.x === b.position.x && b.position.y > a.position.y && nb.position.y <= na.position.y) return false;
|
|
46
|
+
if (b.position.x > a.position.x && b.position.y < a.position.y && (nb.position.x <= na.position.x || nb.position.y >= na.position.y)) return false;
|
|
47
|
+
}
|
|
48
|
+
if (sequence) {
|
|
49
|
+
const ordered = [...before.nodes].sort((a, b) => a.position.x - b.position.x || stable(a, b));
|
|
50
|
+
for (let i = 1; i < ordered.length; i++) if (after.nodes.find(n => n.id === ordered[i].id).position.x <= after.nodes.find(n => n.id === ordered[i - 1].id).position.x) return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A wider participant header pushes every later participant right, then the message spans are restored (the same
|
|
56
|
+
// constraints compileSequence uses). Whatever stands at or right of a participant's old left edge moves with it, so a
|
|
57
|
+
// fragment frame spanning that cut stretches and the horizontal bound still applies.
|
|
58
|
+
function repairSequenceRow(input) {
|
|
59
|
+
const graph = structuredClone(input), limits = layoutLimits(getDiagram('sequence'));
|
|
60
|
+
const nodes = [...graph.nodes].sort((a, b) => a.position.x - b.position.x || stable(a, b)), cuts = nodes.map(node => node.position.x);
|
|
61
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
62
|
+
const shift = nodes[i - 1].position.x + nodes[i - 1].size.width + limits.nodeGap - nodes[i].position.x;
|
|
63
|
+
if (shift > 0) for (let j = i; j < nodes.length; j++) nodes[j].position.x += shift;
|
|
64
|
+
}
|
|
65
|
+
spreadParticipants(graph, nodes);
|
|
66
|
+
const moved = nodes.map((node, i) => node.position.x - cuts[i]);
|
|
67
|
+
if (moved.some(delta => delta > MAX_LOCAL_SHIFT)) return null;
|
|
68
|
+
const follow = x => x + (moved[cuts.findLastIndex(cut => cut <= x)] ?? 0);
|
|
69
|
+
for (const group of graph.groups ?? []) { const right = follow(group.position.x + group.size.width); group.position.x = follow(group.position.x); group.size.width = right - group.position.x; }
|
|
70
|
+
for (const edge of graph.edges) {
|
|
71
|
+
if (edge.route?.via) edge.route.via = edge.route.via.map(point => ({ ...point, x: follow(point.x) }));
|
|
72
|
+
if (edge.route?.labelAt) edge.route.labelAt = { ...edge.route.labelAt, x: follow(edge.route.labelAt.x) };
|
|
73
|
+
}
|
|
74
|
+
return graph;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Expanding edited text may consume both adjacent gaps. Repair that small collision chain
|
|
78
|
+
// together before routing; individual moves cannot pass the gate while another gap is invalid.
|
|
79
|
+
function repairEditedGaps(input, focusId, laneAxis) {
|
|
80
|
+
const graph = structuredClone(input), type = diagramTypeOf(graph), diagram = getDiagram(type), limits = layoutLimits(diagram);
|
|
81
|
+
if (diagram.sequence) return repairSequenceRow(input);
|
|
82
|
+
const changed = new Set([focusId]), related = new Set([focusId]);
|
|
83
|
+
for (let size = 0; size !== related.size;) { size = related.size; for (const edge of graph.edges) if (related.has(edge.source) || related.has(edge.target)) { related.add(edge.source); related.add(edge.target); } }
|
|
84
|
+
for (let step = 0; step < graph.nodes.length * 2; step++) {
|
|
85
|
+
let moved = false;
|
|
86
|
+
for (const a of [...graph.nodes].sort(stable)) for (const b of graph.nodes) {
|
|
87
|
+
if (a.id >= b.id || !changed.has(a.id) && !changed.has(b.id)) continue;
|
|
88
|
+
const x = Math.max(a.position.x, b.position.x) - Math.min(a.position.x + a.size.width, b.position.x + b.size.width);
|
|
89
|
+
const y = Math.max(a.position.y, b.position.y) - Math.min(a.position.y + a.size.height, b.position.y + b.size.height);
|
|
90
|
+
if (Math.hypot(Math.max(0, x), Math.max(0, y)) >= limits.nodeGap) continue;
|
|
91
|
+
const axis = laneAxis === 'x' || ['flowchart', 'state', 'class'].includes(type) ? 'y' : laneAxis === 'y' ? 'x' : x <= 0 ? 'y' : 'x';
|
|
92
|
+
const dimension = axis === 'x' ? 'width' : 'height';
|
|
93
|
+
const first = a.position[axis] <= b.position[axis] ? a : b, last = first === a ? b : a;
|
|
94
|
+
const cut = last.position[axis], shift = first.position[axis] + first.size[dimension] + limits.nodeGap - cut;
|
|
95
|
+
// Grow a row/column gap as a unit, so siblings and following bands keep their alignment.
|
|
96
|
+
for (const item of graph.nodes.filter(node => node.groupId === last.groupId && (related.has(node.id) || Math.abs(node.position[axis] - cut) < .001) && node.position[axis] >= cut)) {
|
|
97
|
+
item.position[axis] += shift;
|
|
98
|
+
const origin = input.nodes.find(n => n.id === item.id).position;
|
|
99
|
+
if (Math.abs(item.position[axis] - origin[axis]) > MAX_LOCAL_SHIFT) return null;
|
|
100
|
+
changed.add(item.id);
|
|
101
|
+
}
|
|
102
|
+
moved = true;
|
|
103
|
+
}
|
|
104
|
+
if (!moved) break;
|
|
105
|
+
}
|
|
106
|
+
const groups = graph.groups ?? [], groupById = new Map(groups.map(group => [group.id, group]));
|
|
107
|
+
const inside = (id, parent) => {
|
|
108
|
+
for (let item = groupById.get(id), depth = 0; item && depth < groups.length; item = groupById.get(item.parentId), depth++) if (item.id === parent) return true;
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
// A growing foreign card can reach a neighbouring ownership frame. Move that entire frame, with its members,
|
|
112
|
+
// rather than leaving a member outside or changing ownership. This is bounded by the same operation origin.
|
|
113
|
+
for (const node of graph.nodes.filter(item => changed.has(item.id))) for (const group of groups) {
|
|
114
|
+
if (inside(node.groupId, group.id)) continue;
|
|
115
|
+
const overlapX = Math.min(node.position.x + node.size.width, group.position.x + group.size.width) - Math.max(node.position.x, group.position.x);
|
|
116
|
+
const overlapY = Math.min(node.position.y + node.size.height, group.position.y + group.size.height) - Math.max(node.position.y, group.position.y);
|
|
117
|
+
if (overlapX <= 0 || overlapY <= 0) continue;
|
|
118
|
+
const moves = ['x', 'y'].map(axis => {
|
|
119
|
+
const dimension = axis === 'x' ? 'width' : 'height';
|
|
120
|
+
const after = group.position[axis] + group.size[dimension] / 2 >= node.position[axis] + node.size[dimension] / 2;
|
|
121
|
+
return { axis, delta: after ? node.position[axis] + node.size[dimension] + limits.nodeGap - group.position[axis] : node.position[axis] - limits.nodeGap - group.position[axis] - group.size[dimension] };
|
|
122
|
+
}).sort((a, b) => Math.abs(a.delta) - Math.abs(b.delta));
|
|
123
|
+
const members = [...groups.filter(item => inside(item.id, group.id)), ...graph.nodes.filter(item => inside(item.groupId, group.id))];
|
|
124
|
+
const movement = moves.find(({axis, delta}) => members.every(item => {
|
|
125
|
+
const original = [...input.nodes, ...(input.groups ?? [])].find(old => old.id === item.id);
|
|
126
|
+
return item.position[axis] + delta >= 0 && Math.abs(item.position[axis] + delta - original.position[axis]) <= MAX_LOCAL_SHIFT;
|
|
127
|
+
}));
|
|
128
|
+
if (!movement) return null;
|
|
129
|
+
for (const item of members) { item.position[movement.axis] += movement.delta; if (graph.nodes.includes(item)) changed.add(item.id); }
|
|
130
|
+
}
|
|
131
|
+
expandOwnershipBounds(graph);
|
|
132
|
+
return graph;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function expandOwnershipBounds(graph) {
|
|
136
|
+
const limits = layoutLimits(getDiagram(diagramTypeOf(graph))), groups = graph.groups ?? [], done = new Set();
|
|
137
|
+
const expand = group => {
|
|
138
|
+
if (done.has(group.id)) return; done.add(group.id);
|
|
139
|
+
const children = groups.filter(g => g.parentId === group.id); children.forEach(expand);
|
|
140
|
+
const members = [...graph.nodes.filter(n => n.groupId === group.id), ...children];
|
|
141
|
+
if (!members.length) return;
|
|
142
|
+
const top = groupHeadingLayout(group).height + limits.groupHeadingGap, inset = limits.groupInset;
|
|
143
|
+
const left = Math.max(0, Math.min(group.position.x, ...members.map(n => n.position.x - inset)));
|
|
144
|
+
const upper = Math.max(0, Math.min(group.position.y, ...members.map(n => n.position.y - top)));
|
|
145
|
+
const right = Math.max(group.position.x + group.size.width, ...members.map(n => n.position.x + n.size.width + inset));
|
|
146
|
+
const lower = Math.max(group.position.y + group.size.height, ...members.map(n => n.position.y + n.size.height + inset));
|
|
147
|
+
group.position = { x: left, y: upper }; group.size = { width: right - left, height: lower - upper };
|
|
148
|
+
};
|
|
149
|
+
groups.forEach(expand);
|
|
150
|
+
return graph;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Geometry only. Ranks, ownership, fields, evidence and sequence events stay authored.
|
|
154
|
+
export function refineDiagramLayout(input, { focusId = null, move = true, evaluations = REFINE_BUDGET, passes = 3, laneAxis = null, growBoundaries = false, global = false, edgeIds: requestedEdges = null } = {}) {
|
|
155
|
+
laneAxis ??= /fold\d+c$/.test(input.layout?.strategy ?? '') ? 'x' : /fold\d+r?$/.test(input.layout?.strategy ?? '') ? 'y' : null;
|
|
156
|
+
const origin = structuredClone(input), ids = scopeIds(origin, focusId), attempts = [];
|
|
157
|
+
let best = structuredClone(input);
|
|
158
|
+
if (growBoundaries) expandOwnershipBounds(best);
|
|
159
|
+
if (growBoundaries && requestedEdges) requestedEdges = affectedRouteIds(origin, best, requestedEdges);
|
|
160
|
+
const strictEdgeIds = requestedEdges ? new Set(requestedEdges) : focusId ? new Set(best.edges.filter(e => ids.has(e.source) || ids.has(e.target)).map(e => e.id)) : new Set(best.edges.map(e => e.id));
|
|
161
|
+
let metrics = layoutMetrics(best, auditLayoutQuality(best, { strictEdgeIds })), routing, count = 0;
|
|
162
|
+
const evaluate = (candidate, movedId = null, full = false) => {
|
|
163
|
+
let edgeIds = !full && requestedEdges ? new Set(requestedEdges) : !full && focusId ? new Set(candidate.edges.filter(e => ids.has(e.source) || ids.has(e.target)).map(e => e.id)) : null;
|
|
164
|
+
if (!global && !full) edgeIds = affectedRouteIds(best, candidate, edgeIds ?? strictEdgeIds);
|
|
165
|
+
count++;
|
|
166
|
+
try {
|
|
167
|
+
if (global) {
|
|
168
|
+
const audit = auditLayoutQuality(candidate), score = layoutMetrics(candidate,audit);
|
|
169
|
+
// Empty-channel compression can preserve a legal path exactly. Keep that improvement before
|
|
170
|
+
// searching new ports; otherwise repair only paths made invalid by the geometry change.
|
|
171
|
+
if (!score.errors && better(score,metrics)) {
|
|
172
|
+
attempts.push({errors:0,cost:score.cost,routing:{termination:'preserved-clear-paths'}});
|
|
173
|
+
best=candidate; metrics=score; return true;
|
|
174
|
+
}
|
|
175
|
+
if (!score.errors && count>1) {
|
|
176
|
+
edgeIds=affectedRouteIds(best,candidate);
|
|
177
|
+
if(!edgeIds.size)return false;
|
|
178
|
+
}
|
|
179
|
+
if (score.errors) {
|
|
180
|
+
const affected = new Set(audit.diagnostics.filter(d=>d.severity==='error').flatMap(d=>d.elementIds));
|
|
181
|
+
edgeIds = new Set(candidate.edges.filter(e=>affected.has(e.id)).map(e=>e.id));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const routed = routeOrthogonal(candidate, { passes: movedId || global && count > 1 ? 1 : passes, edgeIds, accept: requireDiagramQuality });
|
|
185
|
+
const next = routed.graph, score = layoutMetrics(next, auditLayoutQuality(next, { strictEdgeIds: edgeIds ?? strictEdgeIds }));
|
|
186
|
+
attempts.push({ errors: score.errors, cost: score.cost, routing: routed.report });
|
|
187
|
+
if (better(score, metrics)) { for (const id of edgeIds ?? []) strictEdgeIds.add(id); best = next; metrics = score; routing = routed.report; return true; }
|
|
188
|
+
} catch (error) { attempts.push({ reason: error.routingReason ?? 'quality', elementIds: error.elementIds ?? [], routing: error.routingReport }); }
|
|
189
|
+
return false;
|
|
190
|
+
};
|
|
191
|
+
if (evaluations > 0) evaluate(best);
|
|
192
|
+
if (move && focusId && metrics.errors && count < evaluations && !isArchitectureOverview(input)) {
|
|
193
|
+
const repaired = repairEditedGaps(best, focusId, laneAxis);
|
|
194
|
+
if (repaired && preservesOrder(origin, repaired)) evaluate(repaired);
|
|
195
|
+
}
|
|
196
|
+
if (global && move && !isArchitectureOverview(input) && diagramTypeOf(input) !== 'sequence') {
|
|
197
|
+
let improved = true;
|
|
198
|
+
while (improved && count < evaluations) {
|
|
199
|
+
improved = false;
|
|
200
|
+
for (const candidate of compactCandidates(best)) {
|
|
201
|
+
if (count >= evaluations) break;
|
|
202
|
+
// Global row/subtree permutations are constrained by authored ranks/order and the type quality gate.
|
|
203
|
+
if (candidate.nodes.some(n => n.position.x < 0 || n.position.y < 0) || !['architecture', 'er'].includes(diagramTypeOf(input)) && !preservesOrder(origin,candidate)) continue;
|
|
204
|
+
const checked = auditLayoutQuality(candidate);
|
|
205
|
+
if (checked.diagnostics.some(d => d.severity === 'error' && (d.ruleId.startsWith('semantic.') || d.ruleId.startsWith('group.') || d.ruleId === 'spacing.nodes'))) continue;
|
|
206
|
+
if (evaluate(candidate, null, true)) { improved = true; break; }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Presentation grids are realigned by their section planner, never nudged across slots.
|
|
211
|
+
if (move && !global && !isArchitectureOverview(input)) {
|
|
212
|
+
const diagram = getDiagram(diagramTypeOf(input)), target = layoutTargets(diagram).nodeGap;
|
|
213
|
+
let changed = true;
|
|
214
|
+
while (changed && count < evaluations) {
|
|
215
|
+
changed = false;
|
|
216
|
+
const problems = new Set(auditLayoutQuality(best).diagnostics.filter(d => d.severity === 'error').flatMap(d => d.elementIds));
|
|
217
|
+
const nodes = [...best.nodes].filter(n => ids.has(n.id)).sort((a, b) => Number(problems.has(b.id)) - Number(problems.has(a.id)) || stable(a, b));
|
|
218
|
+
for (const node of nodes) {
|
|
219
|
+
const neighbors = best.edges.filter(e => e.source === node.id || e.target === node.id).map(e => best.nodes.find(n => n.id === (e.source === node.id ? e.target : e.source))).filter(n => n.id !== node.id);
|
|
220
|
+
const adjacent = neighbors.length ? neighbors : best.nodes.filter(n => n.id !== node.id && n.groupId === node.groupId);
|
|
221
|
+
const here = node.position, anchor = origin.nodes.find(n => n.id === node.id).position, moves = [];
|
|
222
|
+
for (const other of [...adjacent].sort(stable)) for (const axis of diagram.sequence ? ['x'] : laneAxis ? [laneAxis === 'x' ? 'y' : 'x'] : ['flowchart', 'state', 'class'].includes(diagramTypeOf(input)) ? ['y'] : ['x', 'y']) {
|
|
223
|
+
const dimension = axis === 'x' ? 'width' : 'height', delta = other.position[axis] - here[axis];
|
|
224
|
+
const desired = delta > 0 ? other.position[axis] - node.size[dimension] - target : other.position[axis] + other.size[dimension] + target;
|
|
225
|
+
if (Math.abs(desired - here[axis]) > 1) moves.push({ axis, value: here[axis] + Math.sign(desired - here[axis]) * Math.min(32, Math.abs(desired - here[axis])) });
|
|
226
|
+
}
|
|
227
|
+
if (problems.size) for (const axis of diagram.sequence ? ['x'] : laneAxis ? [laneAxis === 'x' ? 'y' : 'x'] : ['flowchart', 'state', 'class'].includes(diagramTypeOf(input)) ? ['y'] : ['x', 'y']) for (const delta of [-32, 32]) moves.push({ axis, value: here[axis] + delta });
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
for (const { axis, value } of moves) {
|
|
230
|
+
if (count >= evaluations) break;
|
|
231
|
+
const k = axis + ':' + value;
|
|
232
|
+
if (seen.has(k) || value < 0 || Math.abs(value - anchor[axis]) > MAX_LOCAL_SHIFT) continue; seen.add(k);
|
|
233
|
+
const candidate = structuredClone(best); candidate.nodes.find(n => n.id === node.id).position[axis] = value;
|
|
234
|
+
if (!preservesOrder(origin, candidate)) continue;
|
|
235
|
+
const checked = auditLayoutQuality(candidate);
|
|
236
|
+
if (checked.diagnostics.some(d => d.severity === 'error' && (d.ruleId.startsWith('semantic.') || d.ruleId.startsWith('group.') || d.ruleId === 'spacing.nodes'))) continue;
|
|
237
|
+
if (evaluate(candidate, node.id)) { changed = true; break; }
|
|
238
|
+
}
|
|
239
|
+
if (changed || count >= evaluations) break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const report = { budget: evaluations, evaluations: count, termination: count >= evaluations ? 'evaluation-budget' : move ? 'no-improving-local-candidate' : 'routing-only', provenImpossible: false, routing, before: layoutMetrics(origin), after: metrics, attempts };
|
|
244
|
+
try { requireDiagramQuality(best, { strictEdgeIds }); }
|
|
245
|
+
catch (error) { throw Object.assign(error, { routingReport: { ...report, termination: 'no-valid-candidate' } }); }
|
|
246
|
+
const movedNodeIds = best.nodes.filter(n => { const a = origin.nodes.find(o => o.id === n.id); return a.position.x !== n.position.x || a.position.y !== n.position.y; }).map(n => n.id);
|
|
247
|
+
return { graph: best, movedNodeIds, report };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// When upgrading an older positioned graph, its valid corridors are a useful candidate too.
|
|
251
|
+
// The same finite refinement budget is shared across this seed and the fresh template/layered seed.
|
|
252
|
+
export function refineWithLegacySeed(fresh, input, options = {}) {
|
|
253
|
+
const seeds=[fresh];
|
|
254
|
+
if (!currentLayout(input) && diagramTypeOf(input)!=='sequence' && [...input.nodes,...(input.groups??[])].every(n=>n.position&&n.size)) {
|
|
255
|
+
const legacy=structuredClone(input);legacy.layout={...legacy.layout,version:LAYOUT_VERSION,strategy:legacy.layout?.strategy??'legacy-refined'};
|
|
256
|
+
seeds.push(legacy);
|
|
257
|
+
}
|
|
258
|
+
let remaining=options.evaluations??REFINE_BUDGET,best,last,used=0,selectedSeed=0;const reports=[];
|
|
259
|
+
for (const [index,seed] of seeds.entries()) {
|
|
260
|
+
const budget=Math.floor(remaining/(seeds.length-index));
|
|
261
|
+
try {
|
|
262
|
+
const result=refineDiagramLayout(seed,{...options,global:true,evaluations:budget});
|
|
263
|
+
remaining-=result.report.evaluations;used+=result.report.evaluations;reports.push(result.report);
|
|
264
|
+
if(!best||better(result.report.after,best.report.after)){best=result;selectedSeed=index;}
|
|
265
|
+
} catch(error) {
|
|
266
|
+
last=error;const count=error.routingReport?.evaluations??budget;remaining-=count;used+=count;reports.push(error.routingReport??{termination:'no-valid-candidate',evaluations:count});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if(!best)throw Object.assign(last??new Error('No valid refinement candidate'),{routingReport:{budget:options.evaluations??REFINE_BUDGET,evaluations:used,termination:'no-valid-candidate',seeds:reports}});
|
|
270
|
+
return {...best,report:{...best.report,budget:options.evaluations??REFINE_BUDGET,evaluations:used,selectedSeed,...(seeds.length>1?{seeds:reports}:{})}};
|
|
271
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DEPLOYMENT_TIERS = Object.freeze(['external', 'application', 'infrastructure', 'data']);
|
|
2
|
+
export function deploymentTierOf(node) {
|
|
3
|
+
if (node.layout?.tier) return DEPLOYMENT_TIERS.indexOf(node.layout.tier);
|
|
4
|
+
if (['external', 'actor', 'client'].includes(node.kind)) return 0;
|
|
5
|
+
if (node.kind === 'database') return 3;
|
|
6
|
+
if (['queue', 'cache'].includes(node.kind)) return 2;
|
|
7
|
+
return 1;
|
|
8
|
+
}
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
// Graph units: safety limits are shared by generation, editing and export.
|
|
2
2
|
export const LAYOUT_LIMITS = Object.freeze({ nodeGap: 48, labelGap: 24, labelEdgeGap: 6, groupHeadingGap: 24, groupInset: 32, groupGap: 48, endpoint: 12, parallelGap: 24 });
|
|
3
3
|
export const LAYOUT_TARGETS = Object.freeze({ nodeGap: 64, layerGap: 80, edgeNodeGap: 24, labelWidth: 320, headingWidth: 360 });
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// Compact component cards (architecture, deployment) stack closer and sit closer to their boundary; the target keeps 8 units
|
|
5
|
+
// of drag room above the limit.
|
|
6
|
+
const CARD_LIMITS = Object.freeze({ ...LAYOUT_LIMITS, nodeGap: 32, groupInset: 24, groupGap: 32 }), CARD_TARGETS = Object.freeze({ ...LAYOUT_TARGETS, nodeGap: 40, layerGap: 48 });
|
|
7
|
+
export const layoutLimits = diagram => diagram?.cardLayout ? CARD_LIMITS : LAYOUT_LIMITS;
|
|
8
|
+
export const layoutTargets = diagram => diagram?.cardLayout ? CARD_TARGETS : LAYOUT_TARGETS;
|
|
9
|
+
// Aspect is advisory: it triggers folded candidates and breaks equal-cost ties. Safety and semantic rules remain hard limits.
|
|
6
10
|
export const ASPECT_BAND = 1.6;
|
|
7
|
-
//
|
|
8
|
-
// within it win. Without the slack a one-percent overshoot could trigger a fold that costs far more than it fixes.
|
|
11
|
+
// This slack avoids generating fold candidates for a negligible overshoot; every legal candidate uses the normalized score.
|
|
9
12
|
export const ASPECT_SLACK = 1.1;
|
|
10
13
|
// How far a width/height ratio sits outside the band, as a factor ≥ 1; 1 means inside. Callers compare it to two decimals,
|
|
11
14
|
// so a shape within one percent of the band edge counts as inside.
|
|
12
15
|
export const ratioExcess = ratio => Math.max(ratio / ASPECT_BAND, 1 / (ratio * ASPECT_BAND), 1);
|
|
16
|
+
// First-screen zoom floor: fitting everything below it would shrink the 14/16/20px text under about 10px, so the Viewer opens
|
|
17
|
+
// at this scale on the start of the reading flow instead (14/16/20px read as 10.5/12/15px).
|
|
18
|
+
export const READABLE_ZOOM = .75;
|
|
19
|
+
// Whole-view floor: a view that fits the screen at this zoom or more opens whole, its 20/16px text read as at least 9/7.2px
|
|
20
|
+
// like a printed overview; a larger view opens at READABLE_ZOOM on the start of the reading flow.
|
|
21
|
+
export const OVERVIEW_ZOOM = .45;
|
|
22
|
+
// What "one screen" means for the size advisory: the reading rectangle of a 1440×900 window with both panels closed
|
|
23
|
+
// (readingRect), and how many such screens a view may need at READABLE_ZOOM before generation suggests splitting it.
|
|
24
|
+
export const OVERVIEW_AREA = Object.freeze({ width: 1392, height: 688 });
|
|
25
|
+
export const MAX_SCREENS = 4;
|
|
26
|
+
// Layered direction. ER, deployment, data flow and use case read left to right; architecture is laid out both ways and keeps the
|
|
27
|
+
// result that shows the whole view on one OVERVIEW_AREA at the larger zoom. The compiler records its choice in layout.direction,
|
|
28
|
+
// and a direction already there is kept.
|
|
29
|
+
const RIGHT_TYPES = ['er', 'deployment', 'dataflow', 'usecase'];
|
|
30
|
+
export const layeredDirections = type => type === 'architecture' ? ['down', 'right'] : type === 'deployment' ? ['down', 'right'] : [RIGHT_TYPES.includes(type) ? 'right' : 'down'];
|
|
31
|
+
export const layeredDown = (type, layout) => (layout?.direction ?? layeredDirections(type)[0]) === 'down';
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { deploymentTierOf } from './layout-semantics.js';
|
|
2
|
+
import { viewTypeOf } from './view-identity.js';
|
|
3
|
+
import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
|
|
4
|
+
import { minimumNodeSize } from './layout-measure.js';
|
|
5
|
+
import { layoutLimits, layoutTargets, layeredDirections } from './layout-spacing.js';
|
|
6
|
+
import { groupHeadingLayout, estimateLabelSize } from './text-layout.js';
|
|
7
|
+
import { visibleEdgeLabel } from './edge-routing.js';
|
|
8
|
+
|
|
9
|
+
const stable = (a, b) => (a.layout?.order ?? 0) - (b.layout?.order ?? 0) || a.id.localeCompare(b.id, 'en');
|
|
10
|
+
const kinds = item => item.members.map(node => node.kind);
|
|
11
|
+
const has = (item, values) => kinds(item).some(kind => values.includes(kind));
|
|
12
|
+
const degree = (item, edges) => edges.filter(edge => item.ids.has(edge.source) || item.ids.has(edge.target)).length;
|
|
13
|
+
|
|
14
|
+
// Strongly connected components stay together. Cycles never create unbounded ranks, and array order is not a hint.
|
|
15
|
+
function layers(items, edges) {
|
|
16
|
+
const owner = new Map(items.flatMap((item, index) => [...item.ids].map(id => [id, index])));
|
|
17
|
+
const next = items.map(() => new Set());
|
|
18
|
+
for (const edge of edges) { const a = owner.get(edge.source), b = owner.get(edge.target); if (a !== undefined && b !== undefined && a !== b) next[a].add(b); }
|
|
19
|
+
let clock = 0;
|
|
20
|
+
const index = new Map(), low = new Map(), stack = [], active = new Set(), components = [];
|
|
21
|
+
function visit(id) {
|
|
22
|
+
index.set(id, clock); low.set(id, clock++); stack.push(id); active.add(id);
|
|
23
|
+
for (const target of next[id]) {
|
|
24
|
+
if (!index.has(target)) { visit(target); low.set(id, Math.min(low.get(id), low.get(target))); }
|
|
25
|
+
else if (active.has(target)) low.set(id, Math.min(low.get(id), index.get(target)));
|
|
26
|
+
}
|
|
27
|
+
if (low.get(id) === index.get(id)) { const part = []; let member; do { member = stack.pop(); active.delete(member); part.push(member); } while (member !== id); components.push(part); }
|
|
28
|
+
}
|
|
29
|
+
items.forEach((_, i) => { if (!index.has(i)) visit(i); });
|
|
30
|
+
const component = new Map(components.flatMap((part, i) => part.map(id => [id, i]))), rank = new Map();
|
|
31
|
+
function depth(id) {
|
|
32
|
+
if (rank.has(id)) return rank.get(id);
|
|
33
|
+
const parents = new Set();
|
|
34
|
+
next.forEach((targets, source) => { if (component.get(source) !== id && [...targets].some(target => component.get(target) === id)) parents.add(component.get(source)); });
|
|
35
|
+
const value = parents.size ? Math.max(...[...parents].map(depth)) + 1 : 0; rank.set(id, value); return value;
|
|
36
|
+
}
|
|
37
|
+
const rows = [];
|
|
38
|
+
items.forEach((item, i) => { const r = depth(component.get(i)); (rows[r] ??= []).push(item); });
|
|
39
|
+
return rows.filter(Boolean).map(row => row.sort(stable));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function grid(items, ratio = 1.45) {
|
|
43
|
+
if (!items.length) return [];
|
|
44
|
+
if (items.length <= 2) return [items];
|
|
45
|
+
const average = key => items.reduce((sum, item) => sum + item.size[key], 0) / items.length;
|
|
46
|
+
const columns = Math.max(1, Math.min(items.length, Math.round(Math.sqrt(items.length * average('height') * ratio / average('width')))));
|
|
47
|
+
return Array.from({ length: Math.ceil(items.length / columns) }, (_, i) => items.slice(i * columns, (i + 1) * columns));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function components(items, graph) {
|
|
51
|
+
const rows = layers(items, graph.edges);
|
|
52
|
+
if (rows.length === 1 && items.length > 5) return grid(rows[0]);
|
|
53
|
+
return rows.map(row => row.sort((a, b) => String(a.members[0]?.module ?? '').localeCompare(String(b.members[0]?.module ?? '')) || stable(a, b)));
|
|
54
|
+
}
|
|
55
|
+
// Feedback relationships are routed around the spine; they do not collapse its steps into one rank.
|
|
56
|
+
function forwardEdges(graph) {
|
|
57
|
+
const seen = new Set(), active = new Set(), feedback = new Set();
|
|
58
|
+
const next = id => graph.edges.filter(edge => edge.source === id).sort(stable);
|
|
59
|
+
function visit(id) {
|
|
60
|
+
if (seen.has(id)) return;
|
|
61
|
+
seen.add(id); active.add(id);
|
|
62
|
+
for (const edge of next(id)) { if (active.has(edge.target)) feedback.add(edge.id); else visit(edge.target); }
|
|
63
|
+
active.delete(id);
|
|
64
|
+
}
|
|
65
|
+
const first = graph.layout?.primaryPath ?? graph.nodes.filter(node => ['initial', 'start'].includes(node.kind)).map(node => node.id);
|
|
66
|
+
[...first, ...[...graph.nodes].sort(stable).map(node => node.id)].forEach(visit);
|
|
67
|
+
return graph.edges.filter(edge => !feedback.has(edge.id));
|
|
68
|
+
}
|
|
69
|
+
function workflow(items, graph) {
|
|
70
|
+
const path = graph.layout?.primaryPath ?? [];
|
|
71
|
+
const rows = layers(items, forwardEdges(graph));
|
|
72
|
+
if (rows.length === 1 && items.length > 5) return grid(rows[0]);
|
|
73
|
+
return rows.map(row => row.sort((a, b) => {
|
|
74
|
+
const inPath = item => item.members.some(node => path.includes(node.id));
|
|
75
|
+
return Number(inPath(b)) - Number(inPath(a)) || stable(a, b);
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
function entities(items, graph) {
|
|
79
|
+
// Put the most connected table first, then its neighbours; all fields determine actual cell dimensions.
|
|
80
|
+
const remaining = [...items].sort((a, b) => degree(b, graph.edges) - degree(a, graph.edges) || stable(a, b)), ordered = [];
|
|
81
|
+
while (remaining.length) {
|
|
82
|
+
const at = ordered.length ? remaining.findIndex(item => graph.edges.some(edge =>
|
|
83
|
+
item.ids.has(edge.source) && ordered.at(-1).ids.has(edge.target) || item.ids.has(edge.target) && ordered.at(-1).ids.has(edge.source))) : 0;
|
|
84
|
+
ordered.push(...remaining.splice(Math.max(0, at), 1));
|
|
85
|
+
}
|
|
86
|
+
return grid(ordered);
|
|
87
|
+
}
|
|
88
|
+
function deployment(items, graph) {
|
|
89
|
+
// Peers share a runtime tier; dependencies order peers without mixing data with applications.
|
|
90
|
+
const tiers = new Map();
|
|
91
|
+
for (const item of items) { const tier = Math.min(...item.members.map(deploymentTierOf)); tiers.set(tier, [...(tiers.get(tier) ?? []), item]); }
|
|
92
|
+
return [...tiers].sort(([a], [b]) => a - b).flatMap(([, peers]) => layers(peers, graph.edges));
|
|
93
|
+
}
|
|
94
|
+
function classes(items, graph) {
|
|
95
|
+
const hierarchy = graph.edges.filter(edge => ['inheritance', 'implementation'].includes(edge.kind)).map(edge => ({ source: edge.target, target: edge.source }));
|
|
96
|
+
const contracts = items.filter(item => hierarchy.some(edge => item.ids.has(edge.source) || item.ids.has(edge.target)));
|
|
97
|
+
const domain = items.filter(item => !contracts.includes(item));
|
|
98
|
+
// Keep each contract above its implementations, with associated domain classes beside that hierarchy.
|
|
99
|
+
const left = layers(contracts, hierarchy), right = layers(domain, graph.edges);
|
|
100
|
+
const contractBottom = Math.max(-1, ...left.map((row, i) => row.some(item => has(item, ['abstract', 'interface'])) ? i : -1));
|
|
101
|
+
const domainStart = contractBottom + 1;
|
|
102
|
+
return Array.from({ length: Math.max(left.length, domainStart + right.length) }, (_, i) => [...(left[i] ?? []), ...(right[i - domainStart] ?? [])]);
|
|
103
|
+
}
|
|
104
|
+
function lifecycle(items, graph) {
|
|
105
|
+
const initial = items.filter(item => has(item, ['initial'])), final = items.filter(item => has(item, ['final']));
|
|
106
|
+
const states = items.filter(item => !initial.includes(item) && !final.includes(item));
|
|
107
|
+
return [initial, ...layers(states, forwardEdges(graph)), final].filter(row => row.length);
|
|
108
|
+
}
|
|
109
|
+
function usecases(items, graph, variant = 0) {
|
|
110
|
+
const actors = items.filter(item => has(item, ['actor'])), subjects = items.filter(item => !actors.includes(item));
|
|
111
|
+
// At system scope, actors occupy a separate column outside the real boundary. Inside, use cases form a compact matrix.
|
|
112
|
+
return actors.length ? [actors.sort(stable), subjects.sort(stable)].filter(row => row.length) : variant >= 2 ? subjects.sort(stable).map(item => [item]) : entities(subjects, graph);
|
|
113
|
+
}
|
|
114
|
+
function dataflow(items, graph) {
|
|
115
|
+
const stores = items.filter(item => has(item, ['dataStore']));
|
|
116
|
+
const work = items.filter(item => !stores.includes(item));
|
|
117
|
+
// A store mediates data between writers and readers, but is not an extra processing stage.
|
|
118
|
+
const mediated = stores.flatMap(store => graph.edges.filter(edge => store.ids.has(edge.target)).flatMap(write => graph.edges.filter(edge => store.ids.has(edge.source) && edge.target !== write.source).map(read => ({ source: write.source, target: read.target }))));
|
|
119
|
+
const stages = layers(work, [...graph.edges, ...mediated]);
|
|
120
|
+
const stageOf = new Map(stages.flatMap((row, i) => row.flatMap(item => [...item.ids].map(id => [id, i]))));
|
|
121
|
+
for (const store of stores.sort(stable)) {
|
|
122
|
+
const adjacent = graph.edges.filter(edge => store.ids.has(edge.source) || store.ids.has(edge.target)).map(edge => stageOf.get(store.ids.has(edge.source) ? edge.target : edge.source)).filter(i => i !== undefined).sort((a, b) => a - b);
|
|
123
|
+
const at = adjacent.length ? adjacent[Math.floor(adjacent.length / 2)] : Math.max(0, stages.length - 1);
|
|
124
|
+
(stages[at] ??= []).push(store);
|
|
125
|
+
}
|
|
126
|
+
return stages;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const LAYOUT_TEMPLATES = Object.freeze({
|
|
130
|
+
capabilities: { structure: 'capability-bands', existing: 'overview' },
|
|
131
|
+
engineering: { structure: 'engineering-layers', existing: 'overview' },
|
|
132
|
+
relations: { structure: 'component-clusters', arrange: components },
|
|
133
|
+
flowchart: { structure: 'workflow-spine', arrange: workflow },
|
|
134
|
+
sequence: { structure: 'lifelines', existing: 'sequence' },
|
|
135
|
+
er: { structure: 'entity-matrix', arrange: entities },
|
|
136
|
+
deployment: { structure: 'runtime-tiers', arrange: deployment },
|
|
137
|
+
class: { structure: 'contract-hierarchy', arrange: classes },
|
|
138
|
+
state: { structure: 'lifecycle-branches', arrange: lifecycle },
|
|
139
|
+
usecase: { structure: 'actor-system', arrange: usecases },
|
|
140
|
+
dataflow: { structure: 'process-store', arrange: dataflow }
|
|
141
|
+
});
|
|
142
|
+
export const templateOf = graph => LAYOUT_TEMPLATES[viewTypeOf(graph)];
|
|
143
|
+
|
|
144
|
+
// A measured template supplies positions only. Routing and acceptance are the same as for ELK and browser edits.
|
|
145
|
+
// No virtual groups, rank hints, inferred relationships or shortened text are persisted into the semantic model.
|
|
146
|
+
export function templateDraft(input, variant = 0) {
|
|
147
|
+
const template = templateOf(input);
|
|
148
|
+
if (!template?.arrange || (viewTypeOf(input) !== 'deployment' && input.layout?.direction && !input.layout?.version && !input.layout?.strategy?.startsWith('template-'))) return null;
|
|
149
|
+
const linear = input.edges.length === input.nodes.length - 1 && input.nodes.every(node => input.edges.filter(edge => edge.source === node.id).length <= 1 && input.edges.filter(edge => edge.target === node.id).length <= 1);
|
|
150
|
+
if (linear && !['flowchart', 'class', 'state', 'deployment', 'dataflow', 'usecase'].includes(viewTypeOf(input))) return null;
|
|
151
|
+
const graph = structuredClone(input), type = diagramTypeOf(graph), diagram = getDiagram(type), limits = layoutLimits(diagram), target = layoutTargets(diagram);
|
|
152
|
+
graph.nodes.forEach(node => { node.size = minimumNodeSize(node, type, graph.meta.locale); });
|
|
153
|
+
graph.edges.forEach(edge => { delete edge.route; });
|
|
154
|
+
const allGroups = graph.groups ?? [];
|
|
155
|
+
function compose(parentId) {
|
|
156
|
+
const children = allGroups.filter(group => group.parentId === parentId).sort(stable).map(group => {
|
|
157
|
+
const body = compose(group.id), heading = groupHeadingLayout({ ...group, size: undefined });
|
|
158
|
+
const top = heading.height + limits.groupHeadingGap, pad = limits.groupInset;
|
|
159
|
+
group.size = { width: Math.max(heading.width + 2 * pad, body.width + 2 * pad), height: top + body.height + pad };
|
|
160
|
+
return { ...group, members: body.members, ids: new Set(body.members.map(node => node.id)), children: body.items, inset: { x: pad, y: top }, original: group };
|
|
161
|
+
});
|
|
162
|
+
const nodes = graph.nodes.filter(node => node.groupId === parentId).sort(stable).map(node => ({ ...node, members: [node], ids: new Set([node.id]), original: node }));
|
|
163
|
+
const items = [...children, ...nodes].sort(stable);
|
|
164
|
+
const ranked = items.length > 0 && items.every(item => item.members.length && item.members.every(node => node.layout?.rank !== undefined && node.layout.rank === item.members[0].layout.rank));
|
|
165
|
+
let rows = template.arrange(items, graph, variant);
|
|
166
|
+
if (ranked) {
|
|
167
|
+
const ranks = new Map();
|
|
168
|
+
for (const item of items) { const rank = item.members[0].layout.rank; ranks.set(rank, [...(ranks.get(rank) ?? []), item]); }
|
|
169
|
+
rows = [...ranks].sort(([a], [b]) => a - b).map(([, row]) => row.sort(stable));
|
|
170
|
+
}
|
|
171
|
+
if (variant >= 2) {
|
|
172
|
+
// Barycentric sweeps align siblings with their neighbours without changing bands or authored order.
|
|
173
|
+
const owner = new Map(items.flatMap(item => [...item.ids].map(id => [id, item])));
|
|
174
|
+
for (let sweep = 0; sweep < 4; sweep++) {
|
|
175
|
+
const positions = new Map(rows.flatMap(row => row.map((item, i) => [item.id, (i + .5) / row.length])));
|
|
176
|
+
for (const row of (sweep % 2 ? [...rows].reverse() : rows)) {
|
|
177
|
+
if (row.some(item => item.layout?.order !== undefined)) continue;
|
|
178
|
+
const score = item => {
|
|
179
|
+
const peers = graph.edges.flatMap(edge => {
|
|
180
|
+
const a = owner.get(edge.source), b = owner.get(edge.target);
|
|
181
|
+
return a === item && b && !row.includes(b) ? [b] : b === item && a && !row.includes(a) ? [a] : [];
|
|
182
|
+
});
|
|
183
|
+
return peers.length ? peers.reduce((sum, peer) => sum + positions.get(peer.id), 0) / peers.length : positions.get(item.id);
|
|
184
|
+
};
|
|
185
|
+
row.sort((a, b) => score(a) - score(b) || stable(a, b));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const sideways = type === 'architecture' && (input.layout?.direction ?? layeredDirections(type)[0]) === 'right' || type === 'dataflow' || type === 'usecase' && items.some(item => has(item, ['actor'])) || ranked && (input.layout?.direction ?? layeredDirections(type)[0]) === 'right';
|
|
190
|
+
const along = sideways ? 'x' : 'y', across = sideways ? 'y' : 'x', height = sideways ? 'width' : 'height', width = sideways ? 'height' : 'width';
|
|
191
|
+
// Shared column tracks and row baselines are measured once for the whole ownership scope.
|
|
192
|
+
const columnWidths = Array.from({ length: Math.max(0, ...rows.map(row => row.length)) }, (_, column) => Math.max(0, ...rows.map(row => row[column]?.size[width] ?? 0)));
|
|
193
|
+
const lateral = graph.edges.filter(edge => rows.some(row => row.some(item => item.ids.has(edge.source)) && row.some(item => item.ids.has(edge.target)) && !row.some(item => item.ids.has(edge.source) && item.ids.has(edge.target))));
|
|
194
|
+
const widestLabel = Math.max(0, ...lateral.map(edge => estimateLabelSize(visibleEdgeLabel(edge, type, graph.meta.locale))[width]));
|
|
195
|
+
const gap = Math.max(target.nodeGap, Math.min(widestLabel + 2 * limits.labelGap, 240)) + variant * 24;
|
|
196
|
+
const tracks = columnWidths.map((_, column) => columnWidths.slice(0, column).reduce((sum, size) => sum + size + gap, 0));
|
|
197
|
+
const extent = columnWidths.reduce((sum, size) => sum + size, 0) + Math.max(0, columnWidths.length - 1) * gap;
|
|
198
|
+
let cursor = 0;
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
const ids = new Set(row.flatMap(item => [...item.ids]));
|
|
201
|
+
const labels = graph.edges.filter(edge => ids.has(edge.source) || ids.has(edge.target)).map(edge => estimateLabelSize(visibleEdgeLabel(edge, type, graph.meta.locale)));
|
|
202
|
+
const multiplicities = type === 'class' && graph.edges.some(edge => (ids.has(edge.source) || ids.has(edge.target)) && (edge.sourceMultiplicity !== undefined || edge.targetMultiplicity !== undefined));
|
|
203
|
+
const corridor = Math.max(type === 'flowchart' ? 64 : target.layerGap, multiplicities ? 176 : type === 'usecase' && sideways ? Math.max(160, graph.edges.length * 24) : 0, ...labels.map(label => label[height] + 2 * limits.labelGap)) + variant * 24;
|
|
204
|
+
const rowHeight = Math.max(0, ...row.map(item => item.size[height]));
|
|
205
|
+
const centred = ['flowchart', 'state', 'usecase', 'dataflow'].includes(type);
|
|
206
|
+
row.forEach((item, column) => {
|
|
207
|
+
item.position = {
|
|
208
|
+
[along]: cursor + (centred ? (rowHeight - item.size[height]) / 2 : 0),
|
|
209
|
+
[across]: (type === 'architecture' ? row.slice(0,column).reduce((sum,peer)=>sum+peer.size[width]+gap,0) : tracks[column]) + (centred ? (columnWidths[column] - item.size[width]) / 2 : 0)
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
if (row.length === 1 && ['flowchart', 'class', 'state'].includes(type)) row[0].position[across] = (extent - row[0].size[width]) / 2;
|
|
213
|
+
cursor += rowHeight + corridor;
|
|
214
|
+
}
|
|
215
|
+
if (type === 'flowchart' && graph.layout?.primaryPath?.length) {
|
|
216
|
+
const path = new Set(graph.layout.primaryPath), primary = items.filter(item => item.members.some(node => path.has(node.id)));
|
|
217
|
+
const spineWidth = Math.max(0, ...primary.map(item => item.size.width));
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
let branchX = spineWidth + target.nodeGap;
|
|
220
|
+
for (const item of row) {
|
|
221
|
+
if (primary.includes(item)) item.position.x = (spineWidth - item.size.width) / 2;
|
|
222
|
+
else { item.position.x = branchX; branchX += item.size.width + target.nodeGap; }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const size = { width: Math.max(0, ...items.map(item => item.position.x + item.size.width)), height: Math.max(0, ...items.map(item => item.position.y + item.size.height)) };
|
|
227
|
+
return { ...size, items, members: items.flatMap(item => item.members) };
|
|
228
|
+
}
|
|
229
|
+
function place(items, origin) {
|
|
230
|
+
for (const item of items) {
|
|
231
|
+
const position = { x: origin.x + item.position.x, y: origin.y + item.position.y };
|
|
232
|
+
item.original.position = position;
|
|
233
|
+
if (item.children) place(item.children, { x: position.x + item.inset.x, y: position.y + item.inset.y });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const body = compose(undefined);
|
|
237
|
+
// External return corridors may extend above/left of the first node. The export bounds crop this origin.
|
|
238
|
+
const margin = Math.max(64, ...graph.edges.map(edge => estimateLabelSize(visibleEdgeLabel(edge, type, graph.meta.locale)).width + 64));
|
|
239
|
+
place(body.items, { x: margin, y: margin });
|
|
240
|
+
if (type === 'architecture' && variant >= 2) {
|
|
241
|
+
const axis = graph.layout?.direction === 'right' ? 'y' : 'x', cross = axis === 'x' ? 'y' : 'x';
|
|
242
|
+
const extent = axis === 'x' ? 'width' : 'height';
|
|
243
|
+
const rows = new Map();
|
|
244
|
+
for (const node of [...graph.nodes].sort(stable)) { const key = `${node.groupId}:${node.position[cross]}:${node.size[extent]}`; rows.set(key,[...(rows.get(key)??[]),node]); }
|
|
245
|
+
for (let sweep=0;sweep<2;sweep++) {
|
|
246
|
+
const positions=new Map(graph.nodes.map(node=>[node.id,node.position[axis]+node.size[extent]/2]));
|
|
247
|
+
for (const [,row] of [...rows].sort(([a],[b])=>a.localeCompare(b))) {
|
|
248
|
+
if(row.length<2 || row.some(node=>node.layout?.order!==undefined))continue;
|
|
249
|
+
const slots=row.map(node=>node.position[axis]).sort((a,b)=>a-b);
|
|
250
|
+
const score=node=>{ const peers=graph.edges.filter(e=>e.source===node.id||e.target===node.id).map(e=>graph.nodes.find(n=>n.id===(e.source===node.id?e.target:e.source))).filter(n=>n.groupId!==node.groupId);
|
|
251
|
+
return peers.length?peers.reduce((sum,n)=>sum+positions.get(n.id),0)/peers.length:positions.get(node.id); };
|
|
252
|
+
const scores=new Map(row.map(node=>[node.id,score(node)])); row.sort((a,b)=>scores.get(a.id)-scores.get(b.id)||stable(a,b));
|
|
253
|
+
row.forEach((node,i)=>{node.position[axis]=slots[i];});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (type === 'usecase') {
|
|
258
|
+
const actors = graph.nodes.filter(node => node.kind === 'actor');
|
|
259
|
+
for (const actor of actors) {
|
|
260
|
+
const cases = graph.edges.filter(edge => edge.source === actor.id || edge.target === actor.id).map(edge => graph.nodes.find(node => node.id === (edge.source === actor.id ? edge.target : edge.source))).filter(node => node.kind === 'usecase');
|
|
261
|
+
if (cases.length) actor.position.y = cases.reduce((sum, node) => sum + node.position.y + node.size.height / 2, 0) / cases.length - actor.size.height / 2;
|
|
262
|
+
}
|
|
263
|
+
actors.sort((a, b) => a.position.y - b.position.y || stable(a, b));
|
|
264
|
+
for (let i = 1; i < actors.length; i++) actors[i].position.y = Math.max(actors[i].position.y, actors[i - 1].position.y + actors[i - 1].size.height + target.nodeGap);
|
|
265
|
+
}
|
|
266
|
+
graph.layout = { ...graph.layout, direction: type === 'usecase' ? 'right' : type === 'architecture' ? input.layout?.direction ?? layeredDirections(type)[0] : layeredDirections(type)[0] };
|
|
267
|
+
// Seed decision alternatives on opposite outlines; the orthogonal router preserves these semantic exits.
|
|
268
|
+
for (const node of graph.nodes.filter(node => ['decision', 'choice'].includes(node.kind))) {
|
|
269
|
+
const targetX = edge => { const target = graph.nodes.find(item => item.id === edge.target); return target.position.x + target.size.width / 2; };
|
|
270
|
+
const branches = graph.edges.filter(edge => edge.source === node.id && edge.target !== node.id).sort((a, b) => targetX(a) - targetX(b) || stable(a, b));
|
|
271
|
+
branches.forEach((edge, index) => {
|
|
272
|
+
const x = node.position.x + (index % 2 ? node.size.width + 12 : -12), y = node.position.y + node.size.height / 2;
|
|
273
|
+
edge.route = { via: [{ x, y }] };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return graph;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Editing may move a node locally, but must not invert the relative bands/columns of a generated template.
|
|
280
|
+
export function preservesTemplateOrder(before, after) {
|
|
281
|
+
if (diagramTypeOf(before) !== 'sequence') for (const a of before.nodes) for (const b of before.nodes) {
|
|
282
|
+
if (a.id >= b.id || a.groupId !== b.groupId) continue;
|
|
283
|
+
const na = after.nodes.find(node => node.id === a.id), nb = after.nodes.find(node => node.id === b.id);
|
|
284
|
+
for (const [axis, dimension] of [['x', 'width'], ['y', 'height']]) for (const fraction of [0, .5]) {
|
|
285
|
+
const delta = (left, right) => left.position[axis] + fraction * left.size[dimension] - right.position[axis] - fraction * right.size[dimension];
|
|
286
|
+
if (Math.abs(delta(a, b)) < .001 && Math.abs(delta(na, nb)) > .001) return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!before.layout?.strategy?.startsWith('template-')) return true;
|
|
290
|
+
const type = viewTypeOf(before), next = new Map(after.nodes.map(node => [node.id, node]));
|
|
291
|
+
const axis = ['usecase', 'dataflow'].includes(type) ? 'x' : 'y', extent = axis === 'x' ? 'width' : 'height';
|
|
292
|
+
for (const a of before.nodes) for (const b of before.nodes) {
|
|
293
|
+
if (type === 'deployment' && a.id !== b.id && a.groupId === b.groupId && a.position.y === b.position.y && next.get(a.id).position.y !== next.get(b.id).position.y) return false;
|
|
294
|
+
if (a.id === b.id || a.groupId !== b.groupId && !(type === 'usecase' && (a.kind === 'actor' || b.kind === 'actor'))) continue;
|
|
295
|
+
if (a.position[axis] + a.size[extent] <= b.position[axis] && next.get(a.id).position[axis] + next.get(a.id).size[extent] > next.get(b.id).position[axis]) return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
@@ -10,7 +10,7 @@ export function renderNode(node, type, offsetX = 0, offsetY = 0, palette, locale
|
|
|
10
10
|
const content = definition.render({ ...node, appearance }, x, y, fill, stroke, palette, locale);
|
|
11
11
|
// A soft ring behind the frame marks the business center or an explicit failure without touching the text.
|
|
12
12
|
const halo = ring && !['actor', 'initial', 'final'].includes(node.kind) ? paint(definition.outline(node, x, y), { class: 'role-ring', fill: 'none', stroke: ring, 'stroke-width': 5 }) : '';
|
|
13
|
-
const description = [node.label, node.subtitle,
|
|
13
|
+
const description = [node.label, node.subtitle, ...(node.overviewText ?? []), ...(node.badges ?? []).map(badge => badge.label),
|
|
14
14
|
...(node.fields ?? []).map(field => `${field.key ? field.key + ' ' : ''}${field.name}: ${field.type}${field.nullable === undefined ? '' : ` (nullable: ${field.nullable})`}`),
|
|
15
15
|
...(node.attributes ?? []), ...(node.methods ?? [])].filter(Boolean).join('\n');
|
|
16
16
|
return `<g data-diagram-node-id="${escapeXml(node.id)}" class="node-drawing${coreNode(node) && !definition.compartments ? ' core-node' : ''}"><title>${escapeXml(description)}</title>${halo}${content}</g>`;
|