@diagrammo/dgmo 0.15.0 → 0.16.0
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/README.md +23 -10
- package/dist/advanced.cjs +53094 -0
- package/dist/advanced.d.cts +4690 -0
- package/dist/advanced.d.ts +4690 -0
- package/dist/advanced.js +52849 -0
- package/dist/auto.cjs +2298 -2069
- package/dist/auto.js +132 -109
- package/dist/auto.mjs +2294 -2065
- package/dist/cli.cjs +175 -152
- package/dist/editor.cjs +8 -9
- package/dist/editor.js +8 -9
- package/dist/highlight.cjs +8 -9
- package/dist/highlight.js +8 -9
- package/dist/index.cjs +2281 -2048
- package/dist/index.d.cts +45 -1
- package/dist/index.d.ts +45 -1
- package/dist/index.js +2276 -2044
- package/dist/internal.cjs +2064 -1831
- package/dist/internal.d.cts +113 -113
- package/dist/internal.d.ts +113 -113
- package/dist/internal.js +2059 -1826
- package/dist/pert.cjs +325 -0
- package/dist/pert.d.cts +542 -0
- package/dist/pert.d.ts +542 -0
- package/dist/pert.js +294 -0
- package/docs/language-reference.md +83 -66
- package/gallery/fixtures/area.dgmo +3 -3
- package/gallery/fixtures/bar-stacked.dgmo +5 -5
- package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
- package/gallery/fixtures/c4-full.dgmo +8 -8
- package/gallery/fixtures/class-full.dgmo +2 -2
- package/gallery/fixtures/doughnut.dgmo +6 -6
- package/gallery/fixtures/flowchart-colors.dgmo +3 -3
- package/gallery/fixtures/function.dgmo +3 -3
- package/gallery/fixtures/gantt-full.dgmo +9 -9
- package/gallery/fixtures/gantt.dgmo +7 -7
- package/gallery/fixtures/infra-full.dgmo +6 -6
- package/gallery/fixtures/infra.dgmo +2 -2
- package/gallery/fixtures/kanban.dgmo +9 -9
- package/gallery/fixtures/line.dgmo +2 -2
- package/gallery/fixtures/multi-line.dgmo +3 -3
- package/gallery/fixtures/org-full.dgmo +6 -6
- package/gallery/fixtures/quadrant.dgmo +2 -2
- package/gallery/fixtures/sankey.dgmo +9 -9
- package/gallery/fixtures/scatter.dgmo +3 -3
- package/gallery/fixtures/sequence-tags-protocols.dgmo +8 -8
- package/gallery/fixtures/sequence-tags.dgmo +7 -7
- package/gallery/fixtures/sitemap-full.dgmo +7 -7
- package/gallery/fixtures/slope.dgmo +5 -5
- package/gallery/fixtures/spr-eras.dgmo +9 -9
- package/gallery/fixtures/timeline.dgmo +3 -3
- package/gallery/fixtures/venn.dgmo +3 -3
- package/package.json +28 -3
- package/src/advanced.ts +730 -0
- package/src/auto/index.ts +14 -13
- package/src/boxes-and-lines/layout.ts +481 -445
- package/src/boxes-and-lines/renderer.ts +5 -1
- package/src/c4/parser.ts +8 -8
- package/src/c4/renderer.ts +15 -8
- package/src/chart-types.ts +0 -5
- package/src/chart.ts +18 -9
- package/src/class/parser.ts +8 -15
- package/src/class/renderer.ts +17 -6
- package/src/cli.ts +15 -13
- package/src/completion-types.ts +28 -0
- package/src/completion.ts +28 -21
- package/src/cycle/layout.ts +2 -2
- package/src/cycle/parser.ts +14 -0
- package/src/cycle/renderer.ts +6 -3
- package/src/d3.ts +1537 -1164
- package/src/echarts.ts +37 -20
- package/src/editor/dgmo.grammar +1 -3
- package/src/editor/dgmo.grammar.js +8 -8
- package/src/editor/dgmo.grammar.terms.js +11 -12
- package/src/editor/highlight-api.ts +0 -1
- package/src/editor/highlight.ts +0 -1
- package/src/er/parser.ts +19 -20
- package/src/er/renderer.ts +20 -8
- package/src/gantt/calculator.ts +1 -11
- package/src/gantt/parser.ts +17 -17
- package/src/gantt/renderer.ts +9 -6
- package/src/graph/flowchart-parser.ts +19 -85
- package/src/graph/flowchart-renderer.ts +4 -9
- package/src/graph/layout.ts +0 -2
- package/src/graph/state-parser.ts +17 -62
- package/src/graph/state-renderer.ts +4 -9
- package/src/index.ts +17 -1
- package/src/infra/parser.ts +40 -30
- package/src/infra/renderer.ts +9 -6
- package/src/internal.ts +9 -721
- package/src/journey-map/parser.ts +10 -3
- package/src/journey-map/renderer.ts +3 -1
- package/src/kanban/parser.ts +12 -8
- package/src/kanban/renderer.ts +3 -1
- package/src/mindmap/layout.ts +1 -1
- package/src/mindmap/parser.ts +3 -3
- package/src/mindmap/renderer.ts +2 -1
- package/src/org/parser.ts +3 -3
- package/src/org/renderer.ts +5 -4
- package/src/pert/layout.ts +1 -1
- package/src/pert/monte-carlo.ts +2 -2
- package/src/pert/parser.ts +10 -10
- package/src/pert/renderer.ts +7 -2
- package/src/pert/types.ts +1 -1
- package/src/pyramid/parser.ts +12 -0
- package/src/raci/parser.ts +44 -14
- package/src/raci/renderer.ts +3 -2
- package/src/raci/types.ts +4 -3
- package/src/ring/parser.ts +12 -0
- package/src/sequence/parser.ts +15 -9
- package/src/sequence/renderer.ts +2 -5
- package/src/sitemap/layout.ts +0 -2
- package/src/sitemap/parser.ts +12 -38
- package/src/sitemap/renderer.ts +13 -13
- package/src/sitemap/types.ts +0 -1
- package/src/tech-radar/interactive.ts +1 -1
- package/src/tech-radar/renderer.ts +6 -4
- package/src/tech-radar/types.ts +2 -0
- package/src/utils/arrows.ts +3 -28
- package/src/utils/legend-d3.ts +12 -6
- package/src/utils/legend-layout.ts +1 -1
- package/src/utils/legend-types.ts +1 -1
- package/src/utils/parsing.ts +64 -35
- package/src/utils/tag-groups.ts +109 -30
- package/src/wireframe/layout.ts +11 -7
- package/src/wireframe/parser.ts +4 -4
- package/src/wireframe/renderer.ts +5 -2
|
@@ -1,38 +1,17 @@
|
|
|
1
1
|
// ============================================================
|
|
2
2
|
// Boxes and Lines Diagram — Layout Engine
|
|
3
3
|
// ============================================================
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
//
|
|
5
|
+
// Uses elkjs (layered algorithm) with a multi-trial scheme that runs
|
|
6
|
+
// several option variants and picks the best by:
|
|
7
|
+
// 1. crossings (with a forgiveness threshold for near-zero)
|
|
8
|
+
// 2. total area (prefer compact)
|
|
9
|
+
// 3. bend count (prefer fewer corners)
|
|
10
|
+
|
|
11
|
+
import ELK from 'elkjs/lib/elk.bundled.js';
|
|
6
12
|
import type { ParsedBoxesAndLines, BLNode, BLGroup } from './types';
|
|
7
13
|
|
|
8
|
-
/**
|
|
9
|
-
* Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
|
|
10
|
-
* with given width/height, along the direction toward (tx, ty).
|
|
11
|
-
* Returns the intersection point on the rectangle border.
|
|
12
|
-
*/
|
|
13
|
-
function clipToRectBorder(
|
|
14
|
-
cx: number,
|
|
15
|
-
cy: number,
|
|
16
|
-
w: number,
|
|
17
|
-
h: number,
|
|
18
|
-
tx: number,
|
|
19
|
-
ty: number
|
|
20
|
-
): { x: number; y: number } {
|
|
21
|
-
const dx = tx - cx;
|
|
22
|
-
const dy = ty - cy;
|
|
23
|
-
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
|
24
|
-
const hw = w / 2;
|
|
25
|
-
const hh = h / 2;
|
|
26
|
-
// Scale factor to reach the border along the direction (dx, dy)
|
|
27
|
-
const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
|
|
28
|
-
const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
|
|
29
|
-
const s = Math.min(sx, sy);
|
|
30
|
-
return { x: cx + dx * s, y: cy + dy * s };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
14
|
// ── Constants ──────────────────────────────────────────────
|
|
34
|
-
const NODESEP = 60;
|
|
35
|
-
const RANKSEP = 100;
|
|
36
15
|
const MARGIN = 40;
|
|
37
16
|
const CONTAINER_PAD_X = 30;
|
|
38
17
|
const CONTAINER_PAD_TOP = 40;
|
|
@@ -40,6 +19,19 @@ const CONTAINER_PAD_BOTTOM = 24;
|
|
|
40
19
|
const MAX_PARALLEL_EDGES = 5;
|
|
41
20
|
const PARALLEL_SPACING = 22;
|
|
42
21
|
|
|
22
|
+
const PHI = 1.618;
|
|
23
|
+
const NODE_HEIGHT = 60;
|
|
24
|
+
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
25
|
+
const DESC_NODE_WIDTH = 140;
|
|
26
|
+
const DESC_FONT_SIZE = 10;
|
|
27
|
+
const DESC_LINE_HEIGHT = 1.4;
|
|
28
|
+
const DESC_PADDING = 8;
|
|
29
|
+
const SEPARATOR_GAP = 4;
|
|
30
|
+
const MAX_DESC_LINES = 6;
|
|
31
|
+
const MAX_LABEL_LINES = 3;
|
|
32
|
+
const LABEL_LINE_HEIGHT = 1.3;
|
|
33
|
+
const LABEL_PAD = 12;
|
|
34
|
+
|
|
43
35
|
// ── Result types ───────────────────────────────────────────
|
|
44
36
|
|
|
45
37
|
export interface BLLayoutNode {
|
|
@@ -62,7 +54,8 @@ export interface BLLayoutEdge {
|
|
|
62
54
|
yOffset: number;
|
|
63
55
|
parallelCount: number;
|
|
64
56
|
metadata: Record<string, string>;
|
|
65
|
-
/**
|
|
57
|
+
/** Marker for renderer: draw with linear curve, not curveBasis (ELK gives
|
|
58
|
+
* us orthogonal polylines and curveBasis would smooth corners into waves) */
|
|
66
59
|
deferred?: boolean;
|
|
67
60
|
}
|
|
68
61
|
|
|
@@ -87,20 +80,6 @@ export interface BLLayoutResult {
|
|
|
87
80
|
|
|
88
81
|
// ── Node sizing ────────────────────────────────────────────
|
|
89
82
|
|
|
90
|
-
const PHI = 1.618;
|
|
91
|
-
const NODE_HEIGHT = 60;
|
|
92
|
-
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // ≈ 97
|
|
93
|
-
const DESC_NODE_WIDTH = 140; // wider nodes when descriptions are shown
|
|
94
|
-
const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
|
|
95
|
-
const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px (matches infra META_LINE_HEIGHT)
|
|
96
|
-
const DESC_PADDING = 8;
|
|
97
|
-
const SEPARATOR_GAP = 4; // matches infra NODE_SEPARATOR_GAP
|
|
98
|
-
const MAX_DESC_LINES = 6;
|
|
99
|
-
const MAX_LABEL_LINES = 3;
|
|
100
|
-
const LABEL_LINE_HEIGHT = 1.3;
|
|
101
|
-
const LABEL_PAD = 12; // top + bottom padding around label area
|
|
102
|
-
|
|
103
|
-
/** Split on camelCase boundaries */
|
|
104
83
|
function splitCamelCase(word: string): string[] {
|
|
105
84
|
const parts: string[] = [];
|
|
106
85
|
let start = 0;
|
|
@@ -126,21 +105,17 @@ function splitCamelCase(word: string): string[] {
|
|
|
126
105
|
return parts.length > 1 ? parts : [word];
|
|
127
106
|
}
|
|
128
107
|
|
|
129
|
-
/** Estimate how many lines a label needs (split on spaces/dashes/camelCase, font shrink 13→9) */
|
|
130
108
|
function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
|
|
131
|
-
// Split on spaces and dashes, then camelCase
|
|
132
109
|
const rawParts = label.split(/[\s-]+/);
|
|
133
110
|
const words: string[] = [];
|
|
134
111
|
for (const part of rawParts) {
|
|
135
112
|
if (!part) continue;
|
|
136
113
|
words.push(...splitCamelCase(part));
|
|
137
114
|
}
|
|
138
|
-
|
|
139
115
|
for (let fontSize = 13; fontSize >= 9; fontSize--) {
|
|
140
116
|
const charWidth = fontSize * 0.6;
|
|
141
117
|
const maxChars = Math.floor((nodeWidth - 24) / charWidth);
|
|
142
118
|
if (maxChars < 2) continue;
|
|
143
|
-
|
|
144
119
|
let lines = 1;
|
|
145
120
|
let current = '';
|
|
146
121
|
for (const word of words) {
|
|
@@ -161,14 +136,9 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
|
|
|
161
136
|
if (!node.description || node.description.length === 0) {
|
|
162
137
|
return { width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
163
138
|
}
|
|
164
|
-
|
|
165
139
|
const w = DESC_NODE_WIDTH;
|
|
166
|
-
|
|
167
|
-
// Estimate label height (up to 3 lines)
|
|
168
140
|
const labelLines = estimateLabelLines(node.label, w);
|
|
169
141
|
const labelHeight = labelLines * 13 * LABEL_LINE_HEIGHT + LABEL_PAD;
|
|
170
|
-
|
|
171
|
-
// Estimate wrapped line count using word-boundary wrapping (matches renderer)
|
|
172
142
|
const charsPerLine = Math.floor((w - 24) / (DESC_FONT_SIZE * 0.6));
|
|
173
143
|
let totalRenderedLines = 0;
|
|
174
144
|
for (const line of node.description) {
|
|
@@ -179,7 +149,6 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
|
|
|
179
149
|
let current = '';
|
|
180
150
|
let lineCount = 0;
|
|
181
151
|
for (const word of words) {
|
|
182
|
-
// Words wider than line get truncated with "…" in renderer (1 line)
|
|
183
152
|
const fitted =
|
|
184
153
|
word.length > charsPerLine ? word.slice(0, charsPerLine) : word;
|
|
185
154
|
const test = current ? `${current} ${fitted}` : fitted;
|
|
@@ -195,7 +164,6 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
|
|
|
195
164
|
}
|
|
196
165
|
}
|
|
197
166
|
totalRenderedLines = Math.min(totalRenderedLines, MAX_DESC_LINES);
|
|
198
|
-
|
|
199
167
|
const descriptionHeight =
|
|
200
168
|
totalRenderedLines * DESC_FONT_SIZE * DESC_LINE_HEIGHT;
|
|
201
169
|
const totalHeight =
|
|
@@ -204,43 +172,236 @@ function computeNodeSize(node: BLNode): { width: number; height: number } {
|
|
|
204
172
|
DESC_PADDING +
|
|
205
173
|
descriptionHeight +
|
|
206
174
|
DESC_PADDING;
|
|
207
|
-
|
|
208
175
|
return { width: w, height: Math.max(NODE_HEIGHT, totalHeight) };
|
|
209
176
|
}
|
|
210
177
|
|
|
178
|
+
// ── ELK types (minimal) ────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
interface ElkPoint {
|
|
181
|
+
x: number;
|
|
182
|
+
y: number;
|
|
183
|
+
}
|
|
184
|
+
interface ElkEdgeSection {
|
|
185
|
+
id?: string;
|
|
186
|
+
startPoint: ElkPoint;
|
|
187
|
+
endPoint: ElkPoint;
|
|
188
|
+
bendPoints?: ElkPoint[];
|
|
189
|
+
}
|
|
190
|
+
interface ElkLayoutEdge {
|
|
191
|
+
id: string;
|
|
192
|
+
sources: string[];
|
|
193
|
+
targets: string[];
|
|
194
|
+
sections?: ElkEdgeSection[];
|
|
195
|
+
/** ELK marks the container whose local frame the section coords are in */
|
|
196
|
+
container?: string;
|
|
197
|
+
}
|
|
198
|
+
interface ElkNode {
|
|
199
|
+
id: string;
|
|
200
|
+
width?: number;
|
|
201
|
+
height?: number;
|
|
202
|
+
x?: number;
|
|
203
|
+
y?: number;
|
|
204
|
+
children?: ElkNode[];
|
|
205
|
+
edges?: ElkLayoutEdge[];
|
|
206
|
+
labels?: { text: string; width?: number; height?: number }[];
|
|
207
|
+
layoutOptions?: Record<string, string>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let elkInstance: InstanceType<typeof ELK> | null = null;
|
|
211
|
+
function getElk(): InstanceType<typeof ELK> {
|
|
212
|
+
if (!elkInstance) elkInstance = new ELK();
|
|
213
|
+
return elkInstance;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── ELK option variants ────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
interface Variant {
|
|
219
|
+
name: string;
|
|
220
|
+
options: Record<string, string>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function baseOptions(): Record<string, string> {
|
|
224
|
+
return {
|
|
225
|
+
'elk.algorithm': 'layered',
|
|
226
|
+
// INCLUDE_CHILDREN lets ELK route edges across container boundaries.
|
|
227
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
228
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
229
|
+
'elk.layered.unnecessaryBendpoints': 'true',
|
|
230
|
+
// Let edges leave from top/bottom of nodes (not just the flow-direction
|
|
231
|
+
// sides) when it reduces crossings.
|
|
232
|
+
'elk.layered.allowNonFlowPortsToSwitchSides': 'true',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function bkBaseline(): Record<string, string> {
|
|
237
|
+
return {
|
|
238
|
+
...baseOptions(),
|
|
239
|
+
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
240
|
+
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
241
|
+
'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS',
|
|
242
|
+
'elk.layered.compaction.connectedComponents': 'true',
|
|
243
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '90',
|
|
244
|
+
'elk.spacing.nodeNode': '55',
|
|
245
|
+
'elk.spacing.edgeNode': '55',
|
|
246
|
+
'elk.spacing.edgeEdge': '18',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getVariants(): Variant[] {
|
|
251
|
+
const bk = bkBaseline();
|
|
252
|
+
return [
|
|
253
|
+
{
|
|
254
|
+
name: 'bk-baseline',
|
|
255
|
+
options: {
|
|
256
|
+
...bk,
|
|
257
|
+
'elk.layered.crossingMinimization.greedySwitch.type': 'ONE_SIDED',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'bk-aggressive',
|
|
262
|
+
options: {
|
|
263
|
+
...bk,
|
|
264
|
+
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
265
|
+
'elk.layered.thoroughness': '50',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'bk-wide',
|
|
270
|
+
options: {
|
|
271
|
+
...bk,
|
|
272
|
+
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
273
|
+
'elk.layered.thoroughness': '50',
|
|
274
|
+
'elk.spacing.nodeNode': '70',
|
|
275
|
+
'elk.spacing.edgeNode': '75',
|
|
276
|
+
'elk.spacing.edgeEdge': '22',
|
|
277
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: 'longest-path',
|
|
282
|
+
options: {
|
|
283
|
+
...bk,
|
|
284
|
+
'elk.layered.layering.strategy': 'LONGEST_PATH',
|
|
285
|
+
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
286
|
+
'elk.layered.thoroughness': '50',
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'bounded-width',
|
|
291
|
+
options: {
|
|
292
|
+
...bk,
|
|
293
|
+
'elk.layered.layering.strategy': 'COFFMAN_GRAHAM',
|
|
294
|
+
'elk.layered.layering.coffmanGraham.layerBound': '3',
|
|
295
|
+
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
296
|
+
'elk.layered.thoroughness': '50',
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Crossing / quality counters ────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Count visible edge crossings in a layout. Each pair of edge segments is
|
|
306
|
+
* checked for proper intersection (interior, not endpoint-touch).
|
|
307
|
+
* O((E × P)²) where P = avg points per edge. For E~30, P~5, ~22k pairs ≈ 1-3ms.
|
|
308
|
+
*/
|
|
309
|
+
function countCrossings(edges: BLLayoutEdge[]): number {
|
|
310
|
+
let count = 0;
|
|
311
|
+
for (let i = 0; i < edges.length; i++) {
|
|
312
|
+
const a = edges[i].points;
|
|
313
|
+
if (a.length < 2) continue;
|
|
314
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
315
|
+
const b = edges[j].points;
|
|
316
|
+
if (b.length < 2) continue;
|
|
317
|
+
// Skip edges that share an endpoint — they meet at a node, not a crossing
|
|
318
|
+
if (edges[i].source === edges[j].source) continue;
|
|
319
|
+
if (edges[i].source === edges[j].target) continue;
|
|
320
|
+
if (edges[i].target === edges[j].source) continue;
|
|
321
|
+
if (edges[i].target === edges[j].target) continue;
|
|
322
|
+
for (let ai = 0; ai < a.length - 1; ai++) {
|
|
323
|
+
for (let bi = 0; bi < b.length - 1; bi++) {
|
|
324
|
+
if (segmentsCross(a[ai], a[ai + 1], b[bi], b[bi + 1])) count++;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return count;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function segmentsCross(
|
|
333
|
+
p1: ElkPoint,
|
|
334
|
+
p2: ElkPoint,
|
|
335
|
+
p3: ElkPoint,
|
|
336
|
+
p4: ElkPoint
|
|
337
|
+
): boolean {
|
|
338
|
+
const d1x = p2.x - p1.x;
|
|
339
|
+
const d1y = p2.y - p1.y;
|
|
340
|
+
const d2x = p4.x - p3.x;
|
|
341
|
+
const d2y = p4.y - p3.y;
|
|
342
|
+
const denom = d1x * d2y - d1y * d2x;
|
|
343
|
+
if (Math.abs(denom) < 1e-9) return false;
|
|
344
|
+
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denom;
|
|
345
|
+
const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denom;
|
|
346
|
+
const EPS = 0.001;
|
|
347
|
+
return t > EPS && t < 1 - EPS && s > EPS && s < 1 - EPS;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function countTotalBends(edges: BLLayoutEdge[]): number {
|
|
351
|
+
let bends = 0;
|
|
352
|
+
for (const e of edges) bends += Math.max(0, e.points.length - 2);
|
|
353
|
+
return bends;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface LayoutScore {
|
|
357
|
+
crossings: number;
|
|
358
|
+
bends: number;
|
|
359
|
+
area: number;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Up to this many crossings count as equivalent — among near-zero results,
|
|
363
|
+
* compactness decides. Prevents the optimizer picking a sprawling 0-crossing
|
|
364
|
+
* layout over a compact 1-crossing one. */
|
|
365
|
+
const CROSSINGS_FORGIVENESS = 1;
|
|
366
|
+
|
|
367
|
+
function scoreLayout(layout: BLLayoutResult): LayoutScore {
|
|
368
|
+
return {
|
|
369
|
+
crossings: countCrossings(layout.edges),
|
|
370
|
+
bends: countTotalBends(layout.edges),
|
|
371
|
+
area: layout.width * layout.height,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function cmpScore(a: LayoutScore, b: LayoutScore): number {
|
|
376
|
+
const aBucket = a.crossings <= CROSSINGS_FORGIVENESS ? 0 : a.crossings;
|
|
377
|
+
const bBucket = b.crossings <= CROSSINGS_FORGIVENESS ? 0 : b.crossings;
|
|
378
|
+
if (aBucket !== bBucket) return aBucket - bBucket;
|
|
379
|
+
if (a.area !== b.area) return a.area - b.area;
|
|
380
|
+
return a.bends - b.bends;
|
|
381
|
+
}
|
|
382
|
+
|
|
211
383
|
// ── Main layout ────────────────────────────────────────────
|
|
212
384
|
|
|
213
|
-
export function layoutBoxesAndLines(
|
|
385
|
+
export async function layoutBoxesAndLines(
|
|
214
386
|
parsed: ParsedBoxesAndLines,
|
|
215
387
|
collapseInfo?: {
|
|
216
388
|
collapsedChildCounts: Map<string, number>;
|
|
217
|
-
originalGroups:
|
|
389
|
+
originalGroups: BLGroup[];
|
|
218
390
|
},
|
|
219
391
|
layoutOptions?: { hideDescriptions?: boolean }
|
|
220
|
-
): BLLayoutResult {
|
|
392
|
+
): Promise<BLLayoutResult> {
|
|
221
393
|
const hideDescriptions = layoutOptions?.hideDescriptions ?? false;
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
nodesep: NODESEP,
|
|
226
|
-
ranksep: RANKSEP,
|
|
227
|
-
marginx: MARGIN,
|
|
228
|
-
marginy: MARGIN,
|
|
229
|
-
});
|
|
230
|
-
g.setDefaultEdgeLabel(() => ({}));
|
|
231
|
-
|
|
232
|
-
// Determine which groups are collapsed (but not hidden inside a collapsed parent)
|
|
394
|
+
const direction = parsed.direction === 'TB' ? 'DOWN' : 'RIGHT';
|
|
395
|
+
|
|
396
|
+
// Determine which groups are collapsed (shown as plain nodes)
|
|
233
397
|
const collapsedGroupLabels = new Set<string>();
|
|
234
398
|
if (collapseInfo) {
|
|
235
|
-
// Build set of all groups that are missing from parsed (collapsed or hidden)
|
|
236
399
|
const missingGroups = new Set<string>();
|
|
237
400
|
for (const og of collapseInfo.originalGroups) {
|
|
238
401
|
if (!parsed.groups.some((g) => g.label === og.label)) {
|
|
239
402
|
missingGroups.add(og.label);
|
|
240
403
|
}
|
|
241
404
|
}
|
|
242
|
-
// Only show a collapsed group as a node if its parent is NOT also missing
|
|
243
|
-
// (i.e., it's a directly collapsed group, not one hidden inside a collapsed parent)
|
|
244
405
|
for (const label of missingGroups) {
|
|
245
406
|
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
246
407
|
const parentLabel = og?.parentGroup;
|
|
@@ -250,44 +411,7 @@ export function layoutBoxesAndLines(
|
|
|
250
411
|
}
|
|
251
412
|
}
|
|
252
413
|
|
|
253
|
-
//
|
|
254
|
-
for (const label of collapsedGroupLabels) {
|
|
255
|
-
const gid = `__group_${label}`;
|
|
256
|
-
g.setNode(gid, { label, width: NODE_WIDTH, height: NODE_HEIGHT });
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Add expanded group nodes as compound parents
|
|
260
|
-
for (const group of parsed.groups) {
|
|
261
|
-
const gid = `__group_${group.label}`;
|
|
262
|
-
g.setNode(gid, {
|
|
263
|
-
label: group.label,
|
|
264
|
-
paddingLeft: CONTAINER_PAD_X,
|
|
265
|
-
paddingRight: CONTAINER_PAD_X,
|
|
266
|
-
paddingTop: CONTAINER_PAD_TOP,
|
|
267
|
-
paddingBottom: CONTAINER_PAD_BOTTOM,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Re-establish parent relationships for collapsed groups
|
|
272
|
-
// (must run AFTER expanded groups are added to the graph)
|
|
273
|
-
const originalGroupByLabel = new Map<string, BLGroup>();
|
|
274
|
-
if (collapseInfo) {
|
|
275
|
-
for (const og of collapseInfo.originalGroups) {
|
|
276
|
-
originalGroupByLabel.set(og.label, og);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
for (const label of collapsedGroupLabels) {
|
|
280
|
-
const og = originalGroupByLabel.get(label);
|
|
281
|
-
if (og?.parentGroup && !collapsedGroupLabels.has(og.parentGroup)) {
|
|
282
|
-
const gid = `__group_${label}`;
|
|
283
|
-
const parentGid = `__group_${og.parentGroup}`;
|
|
284
|
-
if (g.hasNode(parentGid)) {
|
|
285
|
-
g.setParent(gid, parentGid);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Compute node sizes — described nodes share uniform height (unless hidden)
|
|
414
|
+
// Compute node sizes with uniform-height pass for described nodes
|
|
291
415
|
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
292
416
|
let maxDescHeight = 0;
|
|
293
417
|
for (const node of parsed.nodes) {
|
|
@@ -299,7 +423,6 @@ export function layoutBoxesAndLines(
|
|
|
299
423
|
maxDescHeight = Math.max(maxDescHeight, size.height);
|
|
300
424
|
}
|
|
301
425
|
}
|
|
302
|
-
// Apply uniform height to all described nodes
|
|
303
426
|
if (maxDescHeight > 0) {
|
|
304
427
|
for (const node of parsed.nodes) {
|
|
305
428
|
if (node.description && node.description.length > 0) {
|
|
@@ -309,369 +432,282 @@ export function layoutBoxesAndLines(
|
|
|
309
432
|
}
|
|
310
433
|
}
|
|
311
434
|
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
label: node.label,
|
|
317
|
-
width: size.width,
|
|
318
|
-
height: size.height,
|
|
319
|
-
});
|
|
320
|
-
}
|
|
435
|
+
// Build a fresh ELK graph each variant call — elk.layout() mutates the tree
|
|
436
|
+
// setting x/y/sections, so we can't reuse it across trials.
|
|
437
|
+
const expandedGroupSet = new Set(parsed.groups.map((g) => g.label));
|
|
438
|
+
const gid = (label: string) => `__group_${label}`;
|
|
321
439
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const childGid = `__group_${group.label}`;
|
|
326
|
-
const parentGid = `__group_${group.parentGroup}`;
|
|
327
|
-
if (g.hasNode(childGid) && g.hasNode(parentGid)) {
|
|
328
|
-
g.setParent(childGid, parentGid);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
440
|
+
function buildGraph(): { roots: ElkNode[]; rootEdges: ElkLayoutEdge[] } {
|
|
441
|
+
const nodeById = new Map<string, ElkNode>();
|
|
442
|
+
const parentOf = new Map<string, string>();
|
|
332
443
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (groupLabelSet.has(child)) continue;
|
|
342
|
-
if (g.hasNode(child)) {
|
|
343
|
-
g.setParent(child, gid);
|
|
344
|
-
}
|
|
444
|
+
for (const node of parsed.nodes) {
|
|
445
|
+
const size = nodeSizes.get(node.label)!;
|
|
446
|
+
nodeById.set(node.label, {
|
|
447
|
+
id: node.label,
|
|
448
|
+
width: size.width,
|
|
449
|
+
height: size.height,
|
|
450
|
+
labels: [{ text: node.label }],
|
|
451
|
+
});
|
|
345
452
|
}
|
|
346
|
-
}
|
|
347
453
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
454
|
+
for (const group of parsed.groups) {
|
|
455
|
+
nodeById.set(gid(group.label), {
|
|
456
|
+
id: gid(group.label),
|
|
457
|
+
labels: [{ text: group.label }],
|
|
458
|
+
layoutOptions: {
|
|
459
|
+
'elk.padding': `[top=${CONTAINER_PAD_TOP},left=${CONTAINER_PAD_X},bottom=${CONTAINER_PAD_BOTTOM},right=${CONTAINER_PAD_X}]`,
|
|
460
|
+
// Suggest square-ish containers — has limited effect with
|
|
461
|
+
// INCLUDE_CHILDREN but doesn't hurt.
|
|
462
|
+
'elk.aspectRatio': '1.4',
|
|
463
|
+
},
|
|
464
|
+
children: [],
|
|
465
|
+
edges: [],
|
|
466
|
+
});
|
|
467
|
+
}
|
|
354
468
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
);
|
|
363
|
-
if (firstChild) {
|
|
364
|
-
groupFirstChild.set(gid, firstChild);
|
|
469
|
+
for (const label of collapsedGroupLabels) {
|
|
470
|
+
nodeById.set(gid(label), {
|
|
471
|
+
id: gid(label),
|
|
472
|
+
width: NODE_WIDTH,
|
|
473
|
+
height: NODE_HEIGHT,
|
|
474
|
+
labels: [{ text: label }],
|
|
475
|
+
});
|
|
365
476
|
}
|
|
366
|
-
}
|
|
367
477
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
372
|
-
const edge = parsed.edges[i];
|
|
373
|
-
const src = edge.source;
|
|
374
|
-
const tgt = edge.target;
|
|
375
|
-
if (!g.hasNode(src) || !g.hasNode(tgt)) continue;
|
|
376
|
-
if (expandedGroupIds.has(src) || expandedGroupIds.has(tgt)) {
|
|
377
|
-
deferredEdgeIndices.push(i);
|
|
378
|
-
// Add invisible proxy edge between child nodes so dagre ranks the groups
|
|
379
|
-
const proxySrc = expandedGroupIds.has(src)
|
|
380
|
-
? groupFirstChild.get(src)
|
|
381
|
-
: src;
|
|
382
|
-
const proxyTgt = expandedGroupIds.has(tgt)
|
|
383
|
-
? groupFirstChild.get(tgt)
|
|
384
|
-
: tgt;
|
|
385
|
-
if (proxySrc && proxyTgt && proxySrc !== proxyTgt) {
|
|
386
|
-
g.setEdge(
|
|
387
|
-
proxySrc,
|
|
388
|
-
proxyTgt,
|
|
389
|
-
{ label: '', minlen: 1 },
|
|
390
|
-
`proxy${proxyIdx++}`
|
|
391
|
-
);
|
|
478
|
+
for (const group of parsed.groups) {
|
|
479
|
+
if (group.parentGroup && nodeById.has(gid(group.parentGroup))) {
|
|
480
|
+
parentOf.set(gid(group.label), gid(group.parentGroup));
|
|
392
481
|
}
|
|
393
|
-
continue;
|
|
394
482
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (!dagreNode) continue;
|
|
406
|
-
layoutNodes.push({
|
|
407
|
-
label: node.label,
|
|
408
|
-
x: dagreNode.x,
|
|
409
|
-
y: dagreNode.y,
|
|
410
|
-
width: dagreNode.width,
|
|
411
|
-
height: dagreNode.height,
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Extract group positions (expanded)
|
|
416
|
-
const layoutGroups: BLLayoutGroup[] = [];
|
|
417
|
-
for (const group of parsed.groups) {
|
|
418
|
-
const gid = `__group_${group.label}`;
|
|
419
|
-
const dagreNode = g.node(gid);
|
|
420
|
-
if (!dagreNode) continue;
|
|
421
|
-
layoutGroups.push({
|
|
422
|
-
label: group.label,
|
|
423
|
-
lineNumber: group.lineNumber,
|
|
424
|
-
x: dagreNode.x,
|
|
425
|
-
y: dagreNode.y,
|
|
426
|
-
width: dagreNode.width,
|
|
427
|
-
height: dagreNode.height,
|
|
428
|
-
collapsed: false,
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Extract collapsed group positions
|
|
433
|
-
for (const label of collapsedGroupLabels) {
|
|
434
|
-
const gid = `__group_${label}`;
|
|
435
|
-
const dagreNode = g.node(gid);
|
|
436
|
-
if (!dagreNode) continue;
|
|
437
|
-
const og = collapseInfo?.originalGroups.find((g) => g.label === label);
|
|
438
|
-
layoutGroups.push({
|
|
439
|
-
label,
|
|
440
|
-
lineNumber: og?.lineNumber ?? 0,
|
|
441
|
-
x: dagreNode.x,
|
|
442
|
-
y: dagreNode.y,
|
|
443
|
-
width: dagreNode.width,
|
|
444
|
-
height: dagreNode.height,
|
|
445
|
-
collapsed: true,
|
|
446
|
-
childCount: collapseInfo?.collapsedChildCounts.get(label) ?? 0,
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Center-align groups connected by group-to-group edges.
|
|
451
|
-
// Dagre can't rank expanded compound parents directly, and collapsed groups
|
|
452
|
-
// may also end up misaligned. Post-process to share a common center axis.
|
|
453
|
-
// Track per-group shifts so regular edge points can be adjusted too.
|
|
454
|
-
const groupAlignShifts = new Map<string, number>(); // gid → shift in alignment axis
|
|
455
|
-
{
|
|
456
|
-
// Find all group-to-group edges (both deferred and regular)
|
|
457
|
-
const groupEdges: { source: string; target: string }[] = [];
|
|
458
|
-
for (const edge of parsed.edges) {
|
|
459
|
-
if (
|
|
460
|
-
edge.source.startsWith('__group_') &&
|
|
461
|
-
edge.target.startsWith('__group_')
|
|
462
|
-
) {
|
|
463
|
-
groupEdges.push(edge);
|
|
483
|
+
if (collapseInfo) {
|
|
484
|
+
for (const label of collapsedGroupLabels) {
|
|
485
|
+
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
486
|
+
if (
|
|
487
|
+
og?.parentGroup &&
|
|
488
|
+
!collapsedGroupLabels.has(og.parentGroup) &&
|
|
489
|
+
nodeById.has(gid(og.parentGroup))
|
|
490
|
+
) {
|
|
491
|
+
parentOf.set(gid(label), gid(og.parentGroup));
|
|
492
|
+
}
|
|
464
493
|
}
|
|
465
494
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
while (groupParent.has(x) && groupParent.get(x) !== x) {
|
|
472
|
-
groupParent.set(x, groupParent.get(groupParent.get(x)!)!);
|
|
473
|
-
x = groupParent.get(x)!;
|
|
495
|
+
for (const group of parsed.groups) {
|
|
496
|
+
for (const child of group.children) {
|
|
497
|
+
if (expandedGroupSet.has(child)) continue;
|
|
498
|
+
if (nodeById.has(child)) {
|
|
499
|
+
parentOf.set(child, gid(group.label));
|
|
474
500
|
}
|
|
475
|
-
return x;
|
|
476
|
-
};
|
|
477
|
-
const union = (a: string, b: string) => {
|
|
478
|
-
const ra = find(a),
|
|
479
|
-
rb = find(b);
|
|
480
|
-
if (ra !== rb) groupParent.set(ra, rb);
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
for (const edge of groupEdges) {
|
|
484
|
-
if (!groupParent.has(edge.source))
|
|
485
|
-
groupParent.set(edge.source, edge.source);
|
|
486
|
-
if (!groupParent.has(edge.target))
|
|
487
|
-
groupParent.set(edge.target, edge.target);
|
|
488
|
-
union(edge.source, edge.target);
|
|
489
501
|
}
|
|
502
|
+
}
|
|
490
503
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
504
|
+
const roots: ElkNode[] = [];
|
|
505
|
+
for (const [id, node] of nodeById) {
|
|
506
|
+
const parentId = parentOf.get(id);
|
|
507
|
+
if (parentId) {
|
|
508
|
+
const parent = nodeById.get(parentId)!;
|
|
509
|
+
parent.children = parent.children ?? [];
|
|
510
|
+
parent.children.push(node);
|
|
511
|
+
} else {
|
|
512
|
+
roots.push(node);
|
|
499
513
|
}
|
|
514
|
+
}
|
|
500
515
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const targetCenter = widest[axis];
|
|
511
|
-
|
|
512
|
-
for (const grp of groups) {
|
|
513
|
-
const dx = targetCenter - grp[axis];
|
|
514
|
-
if (dx === 0) continue;
|
|
515
|
-
grp[axis] += dx;
|
|
516
|
-
groupAlignShifts.set(`__group_${grp.label}`, dx);
|
|
517
|
-
// Shift child nodes in this group (expanded groups only)
|
|
518
|
-
const parsedGroup = parsed.groups.find(
|
|
519
|
-
(pg) => pg.label === grp.label
|
|
520
|
-
);
|
|
521
|
-
if (parsedGroup) {
|
|
522
|
-
for (const childLabel of parsedGroup.children) {
|
|
523
|
-
const childNode = layoutNodes.find((n) => n.label === childLabel);
|
|
524
|
-
if (childNode) childNode[axis] += dx;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
516
|
+
const rootEdges: ElkLayoutEdge[] = [];
|
|
517
|
+
for (let i = 0; i < parsed.edges.length; i++) {
|
|
518
|
+
const edge = parsed.edges[i];
|
|
519
|
+
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue;
|
|
520
|
+
rootEdges.push({
|
|
521
|
+
id: `e${i}`,
|
|
522
|
+
sources: [edge.source],
|
|
523
|
+
targets: [edge.target],
|
|
524
|
+
});
|
|
529
525
|
}
|
|
530
|
-
}
|
|
531
526
|
|
|
532
|
-
|
|
533
|
-
const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
|
|
534
|
-
const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
|
|
535
|
-
const parallelGroups = new Map<string, number[]>();
|
|
536
|
-
|
|
537
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
538
|
-
const edge = parsed.edges[i];
|
|
539
|
-
// Normalize key so A→B and B→A are in the same parallel group
|
|
540
|
-
const [a, b] =
|
|
541
|
-
edge.source < edge.target
|
|
542
|
-
? [edge.source, edge.target]
|
|
543
|
-
: [edge.target, edge.source];
|
|
544
|
-
const key = `${a}\x00${b}`;
|
|
545
|
-
if (!parallelGroups.has(key)) parallelGroups.set(key, []);
|
|
546
|
-
parallelGroups.get(key)!.push(i);
|
|
527
|
+
return { roots, rootEdges };
|
|
547
528
|
}
|
|
548
529
|
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
530
|
+
async function runVariant(variant: Variant): Promise<BLLayoutResult> {
|
|
531
|
+
const { roots, rootEdges } = buildGraph();
|
|
532
|
+
const elkRoot: ElkNode = {
|
|
533
|
+
id: 'root',
|
|
534
|
+
layoutOptions: {
|
|
535
|
+
...variant.options,
|
|
536
|
+
'elk.direction': direction,
|
|
537
|
+
'elk.padding': `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`,
|
|
538
|
+
},
|
|
539
|
+
children: roots,
|
|
540
|
+
edges: rootEdges,
|
|
541
|
+
};
|
|
542
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
543
|
+
const result = (await getElk().layout(elkRoot as any)) as ElkNode;
|
|
544
|
+
return extractLayout(result);
|
|
561
545
|
}
|
|
562
546
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
)
|
|
581
|
-
|
|
582
|
-
// Fallback to dagre node positions for collapsed groups / mixed endpoints
|
|
583
|
-
const srcNode = g.node(edge.source);
|
|
584
|
-
const tgtNode = g.node(edge.target);
|
|
585
|
-
if (!srcNode || !tgtNode) continue;
|
|
586
|
-
const srcPt = clipToRectBorder(
|
|
587
|
-
srcNode.x,
|
|
588
|
-
srcNode.y,
|
|
589
|
-
srcNode.width,
|
|
590
|
-
srcNode.height,
|
|
591
|
-
tgtNode.x,
|
|
592
|
-
tgtNode.y
|
|
593
|
-
);
|
|
594
|
-
const tgtPt = clipToRectBorder(
|
|
595
|
-
tgtNode.x,
|
|
596
|
-
tgtNode.y,
|
|
597
|
-
tgtNode.width,
|
|
598
|
-
tgtNode.height,
|
|
599
|
-
srcNode.x,
|
|
600
|
-
srcNode.y
|
|
601
|
-
);
|
|
602
|
-
const midX = (srcPt.x + tgtPt.x) / 2;
|
|
603
|
-
const midY = (srcPt.y + tgtPt.y) / 2;
|
|
604
|
-
points = [srcPt, { x: midX, y: midY }, tgtPt];
|
|
605
|
-
} else if (parsed.direction === 'TB') {
|
|
606
|
-
// TB: straight vertical line from bottom-center to top-center
|
|
607
|
-
const cx = (srcLayout.x + tgtLayout.x) / 2;
|
|
608
|
-
const srcPt = { x: cx, y: srcLayout.y + srcLayout.height / 2 };
|
|
609
|
-
const tgtPt = { x: cx, y: tgtLayout.y - tgtLayout.height / 2 };
|
|
610
|
-
const midY = (srcPt.y + tgtPt.y) / 2;
|
|
611
|
-
points = [srcPt, { x: cx, y: midY }, tgtPt];
|
|
547
|
+
function extractLayout(result: ElkNode): BLLayoutResult {
|
|
548
|
+
const layoutNodes: BLLayoutNode[] = [];
|
|
549
|
+
const layoutGroups: BLLayoutGroup[] = [];
|
|
550
|
+
const allEdges: ElkLayoutEdge[] = [];
|
|
551
|
+
const containerAbs = new Map<string, { x: number; y: number }>();
|
|
552
|
+
|
|
553
|
+
function walk(
|
|
554
|
+
n: ElkNode,
|
|
555
|
+
offsetX: number,
|
|
556
|
+
offsetY: number,
|
|
557
|
+
isRoot: boolean
|
|
558
|
+
): void {
|
|
559
|
+
const nx = (n.x ?? 0) + offsetX;
|
|
560
|
+
const ny = (n.y ?? 0) + offsetY;
|
|
561
|
+
const nw = n.width ?? 0;
|
|
562
|
+
const nh = n.height ?? 0;
|
|
563
|
+
|
|
564
|
+
if (isRoot) {
|
|
565
|
+
containerAbs.set('root', { x: nx, y: ny });
|
|
612
566
|
} else {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
567
|
+
const isGroup = n.id.startsWith('__group_');
|
|
568
|
+
if (isGroup) {
|
|
569
|
+
const label = n.id.slice('__group_'.length);
|
|
570
|
+
const collapsed = collapsedGroupLabels.has(label);
|
|
571
|
+
const og = collapseInfo?.originalGroups.find(
|
|
572
|
+
(g) => g.label === label
|
|
573
|
+
);
|
|
574
|
+
const pg = parsed.groups.find((g) => g.label === label);
|
|
575
|
+
layoutGroups.push({
|
|
576
|
+
label,
|
|
577
|
+
lineNumber: pg?.lineNumber ?? og?.lineNumber ?? 0,
|
|
578
|
+
x: nx + nw / 2,
|
|
579
|
+
y: ny + nh / 2,
|
|
580
|
+
width: nw,
|
|
581
|
+
height: nh,
|
|
582
|
+
collapsed,
|
|
583
|
+
childCount: collapsed
|
|
584
|
+
? (collapseInfo?.collapsedChildCounts.get(label) ?? 0)
|
|
585
|
+
: undefined,
|
|
586
|
+
});
|
|
587
|
+
if (!collapsed) containerAbs.set(n.id, { x: nx, y: ny });
|
|
588
|
+
} else {
|
|
589
|
+
layoutNodes.push({
|
|
590
|
+
label: n.id,
|
|
591
|
+
x: nx + nw / 2,
|
|
592
|
+
y: ny + nh / 2,
|
|
593
|
+
width: nw,
|
|
594
|
+
height: nh,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
619
597
|
}
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
598
|
+
|
|
599
|
+
if (n.edges) for (const e of n.edges) allEdges.push(e);
|
|
600
|
+
if (n.children) for (const c of n.children) walk(c, nx, ny, false);
|
|
601
|
+
}
|
|
602
|
+
walk(result, 0, 0, true);
|
|
603
|
+
|
|
604
|
+
// Parallel edge offsets
|
|
605
|
+
const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
|
|
606
|
+
const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
|
|
607
|
+
const parallelGroups = new Map<string, number[]>();
|
|
608
|
+
for (let i = 0; i < parsed.edges.length; i++) {
|
|
609
|
+
const edge = parsed.edges[i];
|
|
610
|
+
const [a, b] =
|
|
611
|
+
edge.source < edge.target
|
|
612
|
+
? [edge.source, edge.target]
|
|
613
|
+
: [edge.target, edge.source];
|
|
614
|
+
const key = `${a}\x00${b}`;
|
|
615
|
+
if (!parallelGroups.has(key)) parallelGroups.set(key, []);
|
|
616
|
+
parallelGroups.get(key)!.push(i);
|
|
617
|
+
}
|
|
618
|
+
for (const group of parallelGroups.values()) {
|
|
619
|
+
const capped = group.slice(0, MAX_PARALLEL_EDGES);
|
|
620
|
+
for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
|
|
621
|
+
edgeParallelCounts[idx] = 0;
|
|
622
|
+
}
|
|
623
|
+
if (capped.length < 2) continue;
|
|
624
|
+
for (let j = 0; j < capped.length; j++) {
|
|
625
|
+
edgeYOffsets[capped[j]] =
|
|
626
|
+
(j - (capped.length - 1) / 2) * PARALLEL_SPACING;
|
|
627
|
+
edgeParallelCounts[capped[j]] = capped.length;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const edgeById = new Map<string, ElkLayoutEdge>();
|
|
632
|
+
for (const e of allEdges) edgeById.set(e.id, e);
|
|
633
|
+
|
|
634
|
+
const layoutEdges: BLLayoutEdge[] = [];
|
|
635
|
+
for (let i = 0; i < parsed.edges.length; i++) {
|
|
636
|
+
const edge = parsed.edges[i];
|
|
637
|
+
if (edgeParallelCounts[i] === 0) continue;
|
|
638
|
+
const elkEdge = edgeById.get(`e${i}`);
|
|
639
|
+
if (!elkEdge || !elkEdge.sections || elkEdge.sections.length === 0)
|
|
640
|
+
continue;
|
|
641
|
+
const container = elkEdge.container ?? 'root';
|
|
642
|
+
const off = containerAbs.get(container) ?? { x: 0, y: 0 };
|
|
643
|
+
const s = elkEdge.sections[0];
|
|
644
|
+
const points = [
|
|
645
|
+
{ x: s.startPoint.x + off.x, y: s.startPoint.y + off.y },
|
|
646
|
+
...(s.bendPoints ?? []).map((p) => ({
|
|
647
|
+
x: p.x + off.x,
|
|
648
|
+
y: p.y + off.y,
|
|
649
|
+
})),
|
|
650
|
+
{ x: s.endPoint.x + off.x, y: s.endPoint.y + off.y },
|
|
651
|
+
];
|
|
652
|
+
let labelX: number | undefined;
|
|
653
|
+
let labelY: number | undefined;
|
|
654
|
+
if (edge.label && points.length >= 2) {
|
|
655
|
+
const mid = Math.floor(points.length / 2);
|
|
656
|
+
labelX = points[mid].x;
|
|
657
|
+
labelY = points[mid].y - 10;
|
|
630
658
|
}
|
|
659
|
+
layoutEdges.push({
|
|
660
|
+
source: edge.source,
|
|
661
|
+
target: edge.target,
|
|
662
|
+
label: edge.label,
|
|
663
|
+
bidirectional: edge.bidirectional,
|
|
664
|
+
lineNumber: edge.lineNumber,
|
|
665
|
+
points,
|
|
666
|
+
labelX,
|
|
667
|
+
labelY,
|
|
668
|
+
yOffset: edgeYOffsets[i],
|
|
669
|
+
parallelCount: edgeParallelCounts[i],
|
|
670
|
+
metadata: edge.metadata,
|
|
671
|
+
deferred: true,
|
|
672
|
+
});
|
|
631
673
|
}
|
|
632
674
|
|
|
633
|
-
|
|
634
|
-
let
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
675
|
+
let maxX = 0;
|
|
676
|
+
let maxY = 0;
|
|
677
|
+
for (const node of layoutNodes) {
|
|
678
|
+
maxX = Math.max(maxX, node.x + node.width / 2);
|
|
679
|
+
maxY = Math.max(maxY, node.y + node.height / 2);
|
|
680
|
+
}
|
|
681
|
+
for (const group of layoutGroups) {
|
|
682
|
+
maxX = Math.max(maxX, group.x + group.width / 2);
|
|
683
|
+
maxY = Math.max(maxY, group.y + group.height / 2);
|
|
640
684
|
}
|
|
641
685
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
labelX,
|
|
650
|
-
labelY,
|
|
651
|
-
yOffset: edgeYOffsets[i],
|
|
652
|
-
parallelCount: edgeParallelCounts[i],
|
|
653
|
-
metadata: edge.metadata,
|
|
654
|
-
deferred: deferredSet.has(i) || undefined,
|
|
655
|
-
});
|
|
686
|
+
return {
|
|
687
|
+
nodes: layoutNodes,
|
|
688
|
+
edges: layoutEdges,
|
|
689
|
+
groups: layoutGroups,
|
|
690
|
+
width: maxX + MARGIN,
|
|
691
|
+
height: maxY + MARGIN,
|
|
692
|
+
};
|
|
656
693
|
}
|
|
657
694
|
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
695
|
+
// Trivial graphs skip multi-trial — one variant is plenty.
|
|
696
|
+
const N = parsed.nodes.length + parsed.groups.length;
|
|
697
|
+
const E = parsed.edges.length;
|
|
698
|
+
const trivial = N < 8 && E < 10;
|
|
699
|
+
const variants = trivial ? [getVariants()[1]] : getVariants();
|
|
700
|
+
|
|
701
|
+
const results = await Promise.all(variants.map((v) => runVariant(v)));
|
|
702
|
+
|
|
703
|
+
let best = results[0];
|
|
704
|
+
let bestScore = scoreLayout(best);
|
|
705
|
+
for (let i = 1; i < results.length; i++) {
|
|
706
|
+
const s = scoreLayout(results[i]);
|
|
707
|
+
if (cmpScore(s, bestScore) < 0) {
|
|
708
|
+
best = results[i];
|
|
709
|
+
bestScore = s;
|
|
710
|
+
}
|
|
668
711
|
}
|
|
669
|
-
|
|
670
|
-
return {
|
|
671
|
-
nodes: layoutNodes,
|
|
672
|
-
edges: layoutEdges,
|
|
673
|
-
groups: layoutGroups,
|
|
674
|
-
width: maxX + MARGIN,
|
|
675
|
-
height: maxY + MARGIN,
|
|
676
|
-
};
|
|
712
|
+
return best;
|
|
677
713
|
}
|