@diagrammo/dgmo 0.27.0 → 0.28.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/dist/advanced.cjs +1482 -502
- package/dist/advanced.d.cts +6 -0
- package/dist/advanced.d.ts +6 -0
- package/dist/advanced.js +1479 -499
- package/dist/auto.cjs +1483 -503
- package/dist/auto.js +116 -139
- package/dist/auto.mjs +1480 -500
- package/dist/cli.cjs +168 -191
- package/dist/index.cjs +1482 -502
- package/dist/index.js +1479 -499
- package/dist/internal.cjs +1482 -502
- package/dist/internal.d.cts +6 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.js +1479 -499
- package/package.json +7 -3
- package/src/boxes-and-lines/layout-layered.ts +722 -0
- package/src/boxes-and-lines/layout-search.ts +1200 -0
- package/src/boxes-and-lines/layout.ts +67 -556
- package/src/map/context-labels.ts +45 -14
- package/src/map/layout.ts +16 -6
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
// Boxes and Lines Diagram — Layout Engine
|
|
3
3
|
// ============================================================
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 3. bend count (prefer fewer corners)
|
|
5
|
+
// Node sizing + the public `layoutBoxesAndLines` entry. Placement and edge
|
|
6
|
+
// routing are delegated to the dagre placement-search engine (layout-search.ts);
|
|
7
|
+
// this module owns node sizing, parallel-edge fan offsets, and note floating —
|
|
8
|
+
// the engine-agnostic post-passes applied to whatever the engine returns.
|
|
10
9
|
|
|
11
|
-
import ELK from 'elkjs/lib/elk.bundled.js';
|
|
12
10
|
import type { ParsedBoxesAndLines, BLNode, BLGroup } from './types';
|
|
13
11
|
import { measureText, wrapTextToWidth } from '../utils/text-measure';
|
|
14
12
|
import {
|
|
@@ -20,15 +18,12 @@ import {
|
|
|
20
18
|
|
|
21
19
|
// ── Constants ──────────────────────────────────────────────
|
|
22
20
|
const MARGIN = 40;
|
|
23
|
-
const CONTAINER_PAD_X = 30;
|
|
24
|
-
const CONTAINER_PAD_TOP = 40;
|
|
25
|
-
const CONTAINER_PAD_BOTTOM = 24;
|
|
26
21
|
const MAX_PARALLEL_EDGES = 5;
|
|
27
22
|
const PARALLEL_SPACING = 22;
|
|
28
23
|
|
|
29
24
|
const PHI = 1.618;
|
|
30
|
-
const NODE_HEIGHT = 60;
|
|
31
|
-
const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
25
|
+
export const NODE_HEIGHT = 60;
|
|
26
|
+
export const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
|
|
32
27
|
const DESC_NODE_WIDTH = 140;
|
|
33
28
|
const DESC_FONT_SIZE = 10;
|
|
34
29
|
const DESC_LINE_HEIGHT = 1.4;
|
|
@@ -146,7 +141,7 @@ function estimateLabelLines(label: string, nodeWidth = NODE_WIDTH): number {
|
|
|
146
141
|
return MAX_LABEL_LINES;
|
|
147
142
|
}
|
|
148
143
|
|
|
149
|
-
function computeNodeSize(
|
|
144
|
+
export function computeNodeSize(
|
|
150
145
|
node: BLNode,
|
|
151
146
|
reserveValueRow: boolean
|
|
152
147
|
): { width: number; height: number } {
|
|
@@ -186,216 +181,6 @@ function computeNodeSize(
|
|
|
186
181
|
return { width: w, height: Math.max(NODE_HEIGHT, totalHeight) };
|
|
187
182
|
}
|
|
188
183
|
|
|
189
|
-
// ── ELK types (minimal) ────────────────────────────────────
|
|
190
|
-
|
|
191
|
-
interface ElkPoint {
|
|
192
|
-
x: number;
|
|
193
|
-
y: number;
|
|
194
|
-
}
|
|
195
|
-
interface ElkEdgeSection {
|
|
196
|
-
id?: string;
|
|
197
|
-
startPoint: ElkPoint;
|
|
198
|
-
endPoint: ElkPoint;
|
|
199
|
-
bendPoints?: ElkPoint[];
|
|
200
|
-
}
|
|
201
|
-
interface ElkLayoutEdge {
|
|
202
|
-
id: string;
|
|
203
|
-
sources: string[];
|
|
204
|
-
targets: string[];
|
|
205
|
-
sections?: ElkEdgeSection[];
|
|
206
|
-
/** ELK marks the container whose local frame the section coords are in */
|
|
207
|
-
container?: string;
|
|
208
|
-
}
|
|
209
|
-
interface ElkNode {
|
|
210
|
-
id: string;
|
|
211
|
-
width?: number;
|
|
212
|
-
height?: number;
|
|
213
|
-
x?: number;
|
|
214
|
-
y?: number;
|
|
215
|
-
children?: ElkNode[];
|
|
216
|
-
edges?: ElkLayoutEdge[];
|
|
217
|
-
labels?: { text: string; width?: number; height?: number }[];
|
|
218
|
-
layoutOptions?: Record<string, string>;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
let elkInstance: InstanceType<typeof ELK> | null = null;
|
|
222
|
-
function getElk(): InstanceType<typeof ELK> {
|
|
223
|
-
if (!elkInstance) elkInstance = new ELK();
|
|
224
|
-
return elkInstance;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── ELK option variants ────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
interface Variant {
|
|
230
|
-
name: string;
|
|
231
|
-
options: Record<string, string>;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function baseOptions(): Record<string, string> {
|
|
235
|
-
return {
|
|
236
|
-
'elk.algorithm': 'layered',
|
|
237
|
-
// INCLUDE_CHILDREN lets ELK route edges across container boundaries.
|
|
238
|
-
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
239
|
-
'elk.edgeRouting': 'ORTHOGONAL',
|
|
240
|
-
'elk.layered.unnecessaryBendpoints': 'true',
|
|
241
|
-
// Let edges leave from top/bottom of nodes (not just the flow-direction
|
|
242
|
-
// sides) when it reduces crossings.
|
|
243
|
-
'elk.layered.allowNonFlowPortsToSwitchSides': 'true',
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function bkBaseline(): Record<string, string> {
|
|
248
|
-
return {
|
|
249
|
-
...baseOptions(),
|
|
250
|
-
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
251
|
-
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
252
|
-
'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS',
|
|
253
|
-
'elk.layered.compaction.connectedComponents': 'true',
|
|
254
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '90',
|
|
255
|
-
'elk.spacing.nodeNode': '55',
|
|
256
|
-
'elk.spacing.edgeNode': '55',
|
|
257
|
-
'elk.spacing.edgeEdge': '18',
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function getVariants(): Variant[] {
|
|
262
|
-
const bk = bkBaseline();
|
|
263
|
-
return [
|
|
264
|
-
{
|
|
265
|
-
name: 'bk-baseline',
|
|
266
|
-
options: {
|
|
267
|
-
...bk,
|
|
268
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'ONE_SIDED',
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
name: 'bk-aggressive',
|
|
273
|
-
options: {
|
|
274
|
-
...bk,
|
|
275
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
276
|
-
'elk.layered.thoroughness': '50',
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
name: 'bk-wide',
|
|
281
|
-
options: {
|
|
282
|
-
...bk,
|
|
283
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
284
|
-
'elk.layered.thoroughness': '50',
|
|
285
|
-
'elk.spacing.nodeNode': '70',
|
|
286
|
-
'elk.spacing.edgeNode': '75',
|
|
287
|
-
'elk.spacing.edgeEdge': '22',
|
|
288
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
|
|
289
|
-
},
|
|
290
|
-
},
|
|
291
|
-
{
|
|
292
|
-
name: 'longest-path',
|
|
293
|
-
options: {
|
|
294
|
-
...bk,
|
|
295
|
-
'elk.layered.layering.strategy': 'LONGEST_PATH',
|
|
296
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
297
|
-
'elk.layered.thoroughness': '50',
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
{
|
|
301
|
-
name: 'bounded-width',
|
|
302
|
-
options: {
|
|
303
|
-
...bk,
|
|
304
|
-
'elk.layered.layering.strategy': 'COFFMAN_GRAHAM',
|
|
305
|
-
'elk.layered.layering.coffmanGraham.layerBound': '3',
|
|
306
|
-
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
|
307
|
-
'elk.layered.thoroughness': '50',
|
|
308
|
-
},
|
|
309
|
-
},
|
|
310
|
-
];
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ── Crossing / quality counters ────────────────────────────
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Count visible edge crossings in a layout. Each pair of edge segments is
|
|
317
|
-
* checked for proper intersection (interior, not endpoint-touch).
|
|
318
|
-
* O((E × P)²) where P = avg points per edge. For E~30, P~5, ~22k pairs ≈ 1-3ms.
|
|
319
|
-
*/
|
|
320
|
-
function countCrossings(edges: readonly BLLayoutEdge[]): number {
|
|
321
|
-
let count = 0;
|
|
322
|
-
for (let i = 0; i < edges.length; i++) {
|
|
323
|
-
// In-bounds by loop guard.
|
|
324
|
-
const edgeI = edges[i]!;
|
|
325
|
-
const a = edgeI.points;
|
|
326
|
-
if (a.length < 2) continue;
|
|
327
|
-
for (let j = i + 1; j < edges.length; j++) {
|
|
328
|
-
// In-bounds by loop guard.
|
|
329
|
-
const edgeJ = edges[j]!;
|
|
330
|
-
const b = edgeJ.points;
|
|
331
|
-
if (b.length < 2) continue;
|
|
332
|
-
// Skip edges that share an endpoint — they meet at a node, not a crossing
|
|
333
|
-
if (edgeI.source === edgeJ.source) continue;
|
|
334
|
-
if (edgeI.source === edgeJ.target) continue;
|
|
335
|
-
if (edgeI.target === edgeJ.source) continue;
|
|
336
|
-
if (edgeI.target === edgeJ.target) continue;
|
|
337
|
-
for (let ai = 0; ai < a.length - 1; ai++) {
|
|
338
|
-
for (let bi = 0; bi < b.length - 1; bi++) {
|
|
339
|
-
// In-bounds by loop guard (ai < a.length - 1, bi < b.length - 1).
|
|
340
|
-
if (segmentsCross(a[ai]!, a[ai + 1]!, b[bi]!, b[bi + 1]!)) count++;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return count;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function segmentsCross(
|
|
349
|
-
p1: ElkPoint,
|
|
350
|
-
p2: ElkPoint,
|
|
351
|
-
p3: ElkPoint,
|
|
352
|
-
p4: ElkPoint
|
|
353
|
-
): boolean {
|
|
354
|
-
const d1x = p2.x - p1.x;
|
|
355
|
-
const d1y = p2.y - p1.y;
|
|
356
|
-
const d2x = p4.x - p3.x;
|
|
357
|
-
const d2y = p4.y - p3.y;
|
|
358
|
-
const denom = d1x * d2y - d1y * d2x;
|
|
359
|
-
if (Math.abs(denom) < 1e-9) return false;
|
|
360
|
-
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denom;
|
|
361
|
-
const s = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denom;
|
|
362
|
-
const EPS = 0.001;
|
|
363
|
-
return t > EPS && t < 1 - EPS && s > EPS && s < 1 - EPS;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function countTotalBends(edges: readonly BLLayoutEdge[]): number {
|
|
367
|
-
let bends = 0;
|
|
368
|
-
for (const e of edges) bends += Math.max(0, e.points.length - 2);
|
|
369
|
-
return bends;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
interface LayoutScore {
|
|
373
|
-
crossings: number;
|
|
374
|
-
bends: number;
|
|
375
|
-
area: number;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Up to this many crossings count as equivalent — among near-zero results,
|
|
379
|
-
* compactness decides. Prevents the optimizer picking a sprawling 0-crossing
|
|
380
|
-
* layout over a compact 1-crossing one. */
|
|
381
|
-
const CROSSINGS_FORGIVENESS = 1;
|
|
382
|
-
|
|
383
|
-
function scoreLayout(layout: BLLayoutResult): LayoutScore {
|
|
384
|
-
return {
|
|
385
|
-
crossings: countCrossings(layout.edges),
|
|
386
|
-
bends: countTotalBends(layout.edges),
|
|
387
|
-
area: layout.width * layout.height,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function cmpScore(a: LayoutScore, b: LayoutScore): number {
|
|
392
|
-
const aBucket = a.crossings <= CROSSINGS_FORGIVENESS ? 0 : a.crossings;
|
|
393
|
-
const bBucket = b.crossings <= CROSSINGS_FORGIVENESS ? 0 : b.crossings;
|
|
394
|
-
if (aBucket !== bBucket) return aBucket - bBucket;
|
|
395
|
-
if (a.area !== b.area) return a.area - b.area;
|
|
396
|
-
return a.bends - b.bends;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
184
|
// ── Main layout ────────────────────────────────────────────
|
|
400
185
|
|
|
401
186
|
export async function layoutBoxesAndLines(
|
|
@@ -407,342 +192,27 @@ export async function layoutBoxesAndLines(
|
|
|
407
192
|
layoutOptions?: {
|
|
408
193
|
hideDescriptions?: boolean;
|
|
409
194
|
collapsedNotes?: ReadonlySet<number>;
|
|
195
|
+
/** Previous node positions (label → {x,y}) for layout stability —
|
|
196
|
+
* minimizes node drift on edit/collapse. */
|
|
197
|
+
previousPositions?: ReadonlyMap<string, { x: number; y: number }>;
|
|
410
198
|
}
|
|
411
199
|
): Promise<BLLayoutResult> {
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
collapsedGroupLabels.add(label);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Compute node sizes with uniform-height pass for described nodes
|
|
434
|
-
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
435
|
-
let maxDescHeight = 0;
|
|
436
|
-
for (const node of parsed.nodes) {
|
|
437
|
-
const size = hideDescriptions
|
|
438
|
-
? { width: NODE_WIDTH, height: NODE_HEIGHT }
|
|
439
|
-
: computeNodeSize(node, parsed.showValues === true);
|
|
440
|
-
nodeSizes.set(node.label, size);
|
|
441
|
-
if (!hideDescriptions && node.description && node.description.length > 0) {
|
|
442
|
-
maxDescHeight = Math.max(maxDescHeight, size.height);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
if (maxDescHeight > 0) {
|
|
446
|
-
for (const node of parsed.nodes) {
|
|
447
|
-
if (node.description && node.description.length > 0) {
|
|
448
|
-
const size = nodeSizes.get(node.label)!;
|
|
449
|
-
nodeSizes.set(node.label, { width: size.width, height: maxDescHeight });
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Build a fresh ELK graph each variant call — elk.layout() mutates the tree
|
|
455
|
-
// setting x/y/sections, so we can't reuse it across trials.
|
|
456
|
-
const expandedGroupSet = new Set(parsed.groups.map((g) => g.label));
|
|
457
|
-
const gid = (label: string) => `__group_${label}`;
|
|
458
|
-
|
|
459
|
-
function buildGraph(): { roots: ElkNode[]; rootEdges: ElkLayoutEdge[] } {
|
|
460
|
-
const nodeById = new Map<string, ElkNode>();
|
|
461
|
-
const parentOf = new Map<string, string>();
|
|
462
|
-
|
|
463
|
-
for (const node of parsed.nodes) {
|
|
464
|
-
const size = nodeSizes.get(node.label)!;
|
|
465
|
-
nodeById.set(node.label, {
|
|
466
|
-
id: node.label,
|
|
467
|
-
width: size.width,
|
|
468
|
-
height: size.height,
|
|
469
|
-
labels: [{ text: node.label }],
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
for (const group of parsed.groups) {
|
|
474
|
-
nodeById.set(gid(group.label), {
|
|
475
|
-
id: gid(group.label),
|
|
476
|
-
labels: [{ text: group.label }],
|
|
477
|
-
layoutOptions: {
|
|
478
|
-
'elk.padding': `[top=${CONTAINER_PAD_TOP},left=${CONTAINER_PAD_X},bottom=${CONTAINER_PAD_BOTTOM},right=${CONTAINER_PAD_X}]`,
|
|
479
|
-
// Suggest square-ish containers — has limited effect with
|
|
480
|
-
// INCLUDE_CHILDREN but doesn't hurt.
|
|
481
|
-
'elk.aspectRatio': '1.4',
|
|
482
|
-
},
|
|
483
|
-
children: [],
|
|
484
|
-
edges: [],
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
for (const label of collapsedGroupLabels) {
|
|
489
|
-
nodeById.set(gid(label), {
|
|
490
|
-
id: gid(label),
|
|
491
|
-
width: NODE_WIDTH,
|
|
492
|
-
height: NODE_HEIGHT,
|
|
493
|
-
labels: [{ text: label }],
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
for (const group of parsed.groups) {
|
|
498
|
-
if (group.parentGroup && nodeById.has(gid(group.parentGroup))) {
|
|
499
|
-
parentOf.set(gid(group.label), gid(group.parentGroup));
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
if (collapseInfo) {
|
|
503
|
-
for (const label of collapsedGroupLabels) {
|
|
504
|
-
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
505
|
-
if (
|
|
506
|
-
og?.parentGroup &&
|
|
507
|
-
!collapsedGroupLabels.has(og.parentGroup) &&
|
|
508
|
-
nodeById.has(gid(og.parentGroup))
|
|
509
|
-
) {
|
|
510
|
-
parentOf.set(gid(label), gid(og.parentGroup));
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
for (const group of parsed.groups) {
|
|
515
|
-
for (const child of group.children) {
|
|
516
|
-
if (expandedGroupSet.has(child)) continue;
|
|
517
|
-
if (nodeById.has(child)) {
|
|
518
|
-
parentOf.set(child, gid(group.label));
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const roots: ElkNode[] = [];
|
|
524
|
-
for (const [id, node] of nodeById) {
|
|
525
|
-
const parentId = parentOf.get(id);
|
|
526
|
-
if (parentId) {
|
|
527
|
-
const parent = nodeById.get(parentId)!;
|
|
528
|
-
parent.children = parent.children ?? [];
|
|
529
|
-
parent.children.push(node);
|
|
530
|
-
} else {
|
|
531
|
-
roots.push(node);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const rootEdges: ElkLayoutEdge[] = [];
|
|
536
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
537
|
-
// In-bounds by loop guard.
|
|
538
|
-
const edge = parsed.edges[i]!;
|
|
539
|
-
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue;
|
|
540
|
-
rootEdges.push({
|
|
541
|
-
id: `e${i}`,
|
|
542
|
-
sources: [edge.source],
|
|
543
|
-
targets: [edge.target],
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
return { roots, rootEdges };
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
async function runVariant(variant: Variant): Promise<BLLayoutResult> {
|
|
551
|
-
const { roots, rootEdges } = buildGraph();
|
|
552
|
-
const elkRoot: ElkNode = {
|
|
553
|
-
id: 'root',
|
|
554
|
-
layoutOptions: {
|
|
555
|
-
...variant.options,
|
|
556
|
-
'elk.direction': direction,
|
|
557
|
-
'elk.padding': `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`,
|
|
558
|
-
},
|
|
559
|
-
children: roots,
|
|
560
|
-
edges: rootEdges,
|
|
561
|
-
};
|
|
562
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
563
|
-
const result = (await getElk().layout(elkRoot as any)) as ElkNode;
|
|
564
|
-
return extractLayout(result);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function extractLayout(result: ElkNode): BLLayoutResult {
|
|
568
|
-
const layoutNodes: BLLayoutNode[] = [];
|
|
569
|
-
const layoutGroups: BLLayoutGroup[] = [];
|
|
570
|
-
const allEdges: ElkLayoutEdge[] = [];
|
|
571
|
-
const containerAbs = new Map<string, { x: number; y: number }>();
|
|
572
|
-
|
|
573
|
-
function walk(
|
|
574
|
-
n: ElkNode,
|
|
575
|
-
offsetX: number,
|
|
576
|
-
offsetY: number,
|
|
577
|
-
isRoot: boolean
|
|
578
|
-
): void {
|
|
579
|
-
const nx = (n.x ?? 0) + offsetX;
|
|
580
|
-
const ny = (n.y ?? 0) + offsetY;
|
|
581
|
-
const nw = n.width ?? 0;
|
|
582
|
-
const nh = n.height ?? 0;
|
|
583
|
-
|
|
584
|
-
if (isRoot) {
|
|
585
|
-
containerAbs.set('root', { x: nx, y: ny });
|
|
586
|
-
} else {
|
|
587
|
-
const isGroup = n.id.startsWith('__group_');
|
|
588
|
-
if (isGroup) {
|
|
589
|
-
const label = n.id.slice('__group_'.length);
|
|
590
|
-
const collapsed = collapsedGroupLabels.has(label);
|
|
591
|
-
const og = collapseInfo?.originalGroups.find(
|
|
592
|
-
(g) => g.label === label
|
|
593
|
-
);
|
|
594
|
-
const pg = parsed.groups.find((g) => g.label === label);
|
|
595
|
-
const childCount = collapsed
|
|
596
|
-
? (collapseInfo?.collapsedChildCounts.get(label) ?? 0)
|
|
597
|
-
: undefined;
|
|
598
|
-
layoutGroups.push({
|
|
599
|
-
label,
|
|
600
|
-
lineNumber: pg?.lineNumber ?? og?.lineNumber ?? 0,
|
|
601
|
-
x: nx + nw / 2,
|
|
602
|
-
y: ny + nh / 2,
|
|
603
|
-
width: nw,
|
|
604
|
-
height: nh,
|
|
605
|
-
collapsed,
|
|
606
|
-
...(childCount !== undefined && { childCount }),
|
|
607
|
-
});
|
|
608
|
-
if (!collapsed) containerAbs.set(n.id, { x: nx, y: ny });
|
|
609
|
-
} else {
|
|
610
|
-
layoutNodes.push({
|
|
611
|
-
label: n.id,
|
|
612
|
-
x: nx + nw / 2,
|
|
613
|
-
y: ny + nh / 2,
|
|
614
|
-
width: nw,
|
|
615
|
-
height: nh,
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
if (n.edges) for (const e of n.edges) allEdges.push(e);
|
|
621
|
-
if (n.children) for (const c of n.children) walk(c, nx, ny, false);
|
|
622
|
-
}
|
|
623
|
-
walk(result, 0, 0, true);
|
|
624
|
-
|
|
625
|
-
// Parallel edge offsets
|
|
626
|
-
const edgeYOffsets: number[] = new Array(parsed.edges.length).fill(0);
|
|
627
|
-
const edgeParallelCounts: number[] = new Array(parsed.edges.length).fill(1);
|
|
628
|
-
const parallelGroups = new Map<string, number[]>();
|
|
629
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
630
|
-
// In-bounds by loop guard.
|
|
631
|
-
const edge = parsed.edges[i]!;
|
|
632
|
-
const [a, b] =
|
|
633
|
-
edge.source < edge.target
|
|
634
|
-
? [edge.source, edge.target]
|
|
635
|
-
: [edge.target, edge.source];
|
|
636
|
-
const key = `${a}\x00${b}`;
|
|
637
|
-
if (!parallelGroups.has(key)) parallelGroups.set(key, []);
|
|
638
|
-
parallelGroups.get(key)!.push(i);
|
|
639
|
-
}
|
|
640
|
-
for (const group of parallelGroups.values()) {
|
|
641
|
-
const capped = group.slice(0, MAX_PARALLEL_EDGES);
|
|
642
|
-
for (const idx of group.slice(MAX_PARALLEL_EDGES)) {
|
|
643
|
-
edgeParallelCounts[idx] = 0;
|
|
644
|
-
}
|
|
645
|
-
if (capped.length < 2) continue;
|
|
646
|
-
for (let j = 0; j < capped.length; j++) {
|
|
647
|
-
// In-bounds by loop guard.
|
|
648
|
-
const cappedJ = capped[j]!;
|
|
649
|
-
edgeYOffsets[cappedJ] =
|
|
650
|
-
(j - (capped.length - 1) / 2) * PARALLEL_SPACING;
|
|
651
|
-
edgeParallelCounts[cappedJ] = capped.length;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const edgeById = new Map<string, ElkLayoutEdge>();
|
|
656
|
-
for (const e of allEdges) edgeById.set(e.id, e);
|
|
657
|
-
|
|
658
|
-
const layoutEdges: BLLayoutEdge[] = [];
|
|
659
|
-
for (let i = 0; i < parsed.edges.length; i++) {
|
|
660
|
-
// In-bounds by loop guard.
|
|
661
|
-
const edge = parsed.edges[i]!;
|
|
662
|
-
if (edgeParallelCounts[i] === 0) continue;
|
|
663
|
-
const elkEdge = edgeById.get(`e${i}`);
|
|
664
|
-
if (!elkEdge?.sections || elkEdge.sections.length === 0) continue;
|
|
665
|
-
const container = elkEdge.container ?? 'root';
|
|
666
|
-
const off = containerAbs.get(container) ?? { x: 0, y: 0 };
|
|
667
|
-
// In-bounds — length check above guarantees sections[0] exists.
|
|
668
|
-
const s = elkEdge.sections[0]!;
|
|
669
|
-
const points = [
|
|
670
|
-
{ x: s.startPoint.x + off.x, y: s.startPoint.y + off.y },
|
|
671
|
-
...(s.bendPoints ?? []).map((p) => ({
|
|
672
|
-
x: p.x + off.x,
|
|
673
|
-
y: p.y + off.y,
|
|
674
|
-
})),
|
|
675
|
-
{ x: s.endPoint.x + off.x, y: s.endPoint.y + off.y },
|
|
676
|
-
];
|
|
677
|
-
let labelX: number | undefined;
|
|
678
|
-
let labelY: number | undefined;
|
|
679
|
-
if (edge.label && points.length >= 2) {
|
|
680
|
-
const mid = Math.floor(points.length / 2);
|
|
681
|
-
// In-bounds — mid < points.length guaranteed by length >= 2 check.
|
|
682
|
-
const midPoint = points[mid]!;
|
|
683
|
-
labelX = midPoint.x;
|
|
684
|
-
labelY = midPoint.y - 10;
|
|
685
|
-
}
|
|
686
|
-
layoutEdges.push({
|
|
687
|
-
source: edge.source,
|
|
688
|
-
target: edge.target,
|
|
689
|
-
...(edge.label !== undefined && { label: edge.label }),
|
|
690
|
-
bidirectional: edge.bidirectional,
|
|
691
|
-
lineNumber: edge.lineNumber,
|
|
692
|
-
points,
|
|
693
|
-
...(labelX !== undefined && { labelX }),
|
|
694
|
-
...(labelY !== undefined && { labelY }),
|
|
695
|
-
// In-bounds — i < parsed.edges.length, arrays sized to that length.
|
|
696
|
-
yOffset: edgeYOffsets[i]!,
|
|
697
|
-
parallelCount: edgeParallelCounts[i]!,
|
|
698
|
-
metadata: edge.metadata,
|
|
699
|
-
deferred: true,
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
let maxX = 0;
|
|
704
|
-
let maxY = 0;
|
|
705
|
-
for (const node of layoutNodes) {
|
|
706
|
-
maxX = Math.max(maxX, node.x + node.width / 2);
|
|
707
|
-
maxY = Math.max(maxY, node.y + node.height / 2);
|
|
708
|
-
}
|
|
709
|
-
for (const group of layoutGroups) {
|
|
710
|
-
maxX = Math.max(maxX, group.x + group.width / 2);
|
|
711
|
-
maxY = Math.max(maxY, group.y + group.height / 2);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
return {
|
|
715
|
-
nodes: layoutNodes,
|
|
716
|
-
edges: layoutEdges,
|
|
717
|
-
groups: layoutGroups,
|
|
718
|
-
width: maxX + MARGIN,
|
|
719
|
-
height: maxY + MARGIN,
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Trivial graphs skip multi-trial — one variant is plenty.
|
|
724
|
-
const N = parsed.nodes.length + parsed.groups.length;
|
|
725
|
-
const E = parsed.edges.length;
|
|
726
|
-
const trivial = N < 8 && E < 10;
|
|
727
|
-
// In-bounds — getVariants() returns 5 variants, index 1 always exists.
|
|
728
|
-
const variants = trivial ? [getVariants()[1]!] : getVariants();
|
|
729
|
-
|
|
730
|
-
const results = await Promise.all(variants.map((v) => runVariant(v)));
|
|
731
|
-
|
|
732
|
-
// In-bounds — variants is non-empty (trivial branch has 1, normal has 5).
|
|
733
|
-
let best = results[0]!;
|
|
734
|
-
let bestScore = scoreLayout(best);
|
|
735
|
-
for (let i = 1; i < results.length; i++) {
|
|
736
|
-
// In-bounds by loop guard.
|
|
737
|
-
const resultI = results[i]!;
|
|
738
|
-
const s = scoreLayout(resultI);
|
|
739
|
-
if (cmpScore(s, bestScore) < 0) {
|
|
740
|
-
best = resultI;
|
|
741
|
-
bestScore = s;
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
return attachNotes(best, parsed, layoutOptions?.collapsedNotes);
|
|
200
|
+
const { layoutBoxesAndLinesSearch } = await import('./layout-search');
|
|
201
|
+
const searched = layoutBoxesAndLinesSearch(parsed, collapseInfo, {
|
|
202
|
+
...(layoutOptions?.hideDescriptions !== undefined && {
|
|
203
|
+
hideDescriptions: layoutOptions.hideDescriptions,
|
|
204
|
+
}),
|
|
205
|
+
...(layoutOptions?.previousPositions !== undefined && {
|
|
206
|
+
previousPositions: layoutOptions.previousPositions,
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
// Engine-agnostic post-processing: fan parallel edges, then float notes
|
|
210
|
+
// (and shift the canvas to fit them).
|
|
211
|
+
return attachNotes(
|
|
212
|
+
applyParallelEdgeOffsets(searched),
|
|
213
|
+
parsed,
|
|
214
|
+
layoutOptions?.collapsedNotes
|
|
215
|
+
);
|
|
746
216
|
}
|
|
747
217
|
|
|
748
218
|
/**
|
|
@@ -850,3 +320,44 @@ function attachNotes(
|
|
|
850
320
|
height: bbMaxY + shiftY + MARGIN,
|
|
851
321
|
};
|
|
852
322
|
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Assign parallel-edge fan offsets on any layout (engine-agnostic). Edges sharing
|
|
326
|
+
* an unordered {source,target} pair are bundled at their ports and spread in the
|
|
327
|
+
* middle by the renderer using `yOffset`/`parallelCount`; beyond `MAX_PARALLEL_EDGES`
|
|
328
|
+
* the extras are dropped (`parallelCount: 0` ⇒ renderer skips them). The ELK path
|
|
329
|
+
* computes this inside extractLayout; the search engine produces a single set of
|
|
330
|
+
* points per pair, so it needs the same offsets applied here.
|
|
331
|
+
*/
|
|
332
|
+
function applyParallelEdgeOffsets(layout: BLLayoutResult): BLLayoutResult {
|
|
333
|
+
const groups = new Map<string, number[]>();
|
|
334
|
+
layout.edges.forEach((e, i) => {
|
|
335
|
+
const [a, b] =
|
|
336
|
+
e.source < e.target ? [e.source, e.target] : [e.target, e.source];
|
|
337
|
+
const key = `${a}\x00${b}`;
|
|
338
|
+
const arr = groups.get(key);
|
|
339
|
+
if (arr) arr.push(i);
|
|
340
|
+
else groups.set(key, [i]);
|
|
341
|
+
});
|
|
342
|
+
if ([...groups.values()].every((g) => g.length < 2)) return layout;
|
|
343
|
+
|
|
344
|
+
const yOffset = new Array(layout.edges.length).fill(0);
|
|
345
|
+
const count = new Array(layout.edges.length).fill(1);
|
|
346
|
+
for (const idxs of groups.values()) {
|
|
347
|
+
const capped = idxs.slice(0, MAX_PARALLEL_EDGES);
|
|
348
|
+
for (const drop of idxs.slice(MAX_PARALLEL_EDGES)) count[drop] = 0;
|
|
349
|
+
if (capped.length < 2) continue;
|
|
350
|
+
capped.forEach((idx, j) => {
|
|
351
|
+
yOffset[idx] = (j - (capped.length - 1) / 2) * PARALLEL_SPACING;
|
|
352
|
+
count[idx] = capped.length;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
...layout,
|
|
357
|
+
edges: layout.edges.map((e, i) => ({
|
|
358
|
+
...e,
|
|
359
|
+
yOffset: yOffset[i]!,
|
|
360
|
+
parallelCount: count[i]!,
|
|
361
|
+
})),
|
|
362
|
+
};
|
|
363
|
+
}
|