@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.
@@ -2,13 +2,11 @@
2
2
  // Boxes and Lines Diagram — Layout Engine
3
3
  // ============================================================
4
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)
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 hideDescriptions = layoutOptions?.hideDescriptions ?? false;
413
- const direction = parsed.direction === 'TB' ? 'DOWN' : 'RIGHT';
414
-
415
- // Determine which groups are collapsed (shown as plain nodes)
416
- const collapsedGroupLabels = new Set<string>();
417
- if (collapseInfo) {
418
- const missingGroups = new Set<string>();
419
- for (const og of collapseInfo.originalGroups) {
420
- if (!parsed.groups.some((g) => g.label === og.label)) {
421
- missingGroups.add(og.label);
422
- }
423
- }
424
- for (const label of missingGroups) {
425
- const og = collapseInfo.originalGroups.find((g) => g.label === label);
426
- const parentLabel = og?.parentGroup;
427
- if (!parentLabel || !missingGroups.has(parentLabel)) {
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
+ }