@nodius/layouting 0.1.1 → 0.1.4

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/index.mjs CHANGED
@@ -1,11 +1,32 @@
1
1
  // src/types.ts
2
+ var QUALITY_PROFILES = {
3
+ draft: {
4
+ crossingMinimizationIterations: 6,
5
+ coordinateOptimizationIterations: 2,
6
+ skipTranspose: true
7
+ },
8
+ balanced: {
9
+ crossingMinimizationIterations: 24,
10
+ coordinateOptimizationIterations: 8,
11
+ skipTranspose: false
12
+ },
13
+ high: {
14
+ crossingMinimizationIterations: 48,
15
+ coordinateOptimizationIterations: 16,
16
+ skipTranspose: false
17
+ }
18
+ };
2
19
  function resolveOptions(options) {
20
+ const quality = options?.quality ?? "balanced";
21
+ const profile = QUALITY_PROFILES[quality];
3
22
  return {
4
23
  direction: options?.direction ?? "TB",
24
+ quality,
5
25
  nodeSpacing: options?.nodeSpacing ?? 40,
6
26
  layerSpacing: options?.layerSpacing ?? 60,
7
- crossingMinimizationIterations: options?.crossingMinimizationIterations ?? 24,
8
- coordinateOptimizationIterations: options?.coordinateOptimizationIterations ?? 8,
27
+ crossingMinimizationIterations: options?.crossingMinimizationIterations ?? profile.crossingMinimizationIterations,
28
+ coordinateOptimizationIterations: options?.coordinateOptimizationIterations ?? profile.coordinateOptimizationIterations,
29
+ skipTranspose: options?.skipTranspose ?? profile.skipTranspose,
9
30
  edgeMargin: options?.edgeMargin ?? 20,
10
31
  controlWeight: options?.edgeWeights?.control ?? 1,
11
32
  dataWeight: options?.edgeWeights?.data ?? 0.25,
@@ -370,11 +391,11 @@ function insertDummyNodes(graph, layers) {
370
391
  }
371
392
 
372
393
  // src/algorithms/crossing-minimization.ts
373
- function minimizeCrossings(graph, layers, iterations) {
394
+ function minimizeCrossings(graph, layers, iterations, forceSkipTranspose = false) {
374
395
  if (layers.length <= 1) return layers;
375
396
  const totalNodes = layers.reduce((s, l) => s + l.length, 0);
376
397
  const effectiveIter = totalNodes > 500 ? Math.min(iterations, 6) : totalNodes > 200 ? Math.min(iterations, 12) : iterations;
377
- const skipTranspose = totalNodes > 800;
398
+ const skipTranspose = forceSkipTranspose || totalNodes > 800;
378
399
  for (let l = 0; l < layers.length; l++) {
379
400
  for (let i = 0; i < layers[l].length; i++) {
380
401
  const node = graph.nodes.get(layers[l][i]);
@@ -421,45 +442,54 @@ function orderByBarycenter(graph, layers, layerIndex, direction) {
421
442
  const layer = layers[layerIndex];
422
443
  const adjLayerIndex = direction === "up" ? layerIndex - 1 : layerIndex + 1;
423
444
  if (adjLayerIndex < 0 || adjLayerIndex >= layers.length) return;
424
- const adjLayerSet = new Set(layers[adjLayerIndex]);
425
- const barycenters = /* @__PURE__ */ new Map();
426
- for (let i = 0; i < layer.length; i++) {
427
- const nodeId = layer[i];
428
- let sum = 0;
429
- let count = 0;
430
- if (direction === "up") {
445
+ const n = layer.length;
446
+ const bary = new Float64Array(n);
447
+ if (direction === "up") {
448
+ for (let i = 0; i < n; i++) {
449
+ const nodeId = layer[i];
450
+ let sum = 0;
451
+ let count = 0;
431
452
  const inEdgeIds = graph.inEdges.get(nodeId);
432
453
  if (inEdgeIds) {
433
454
  for (const eid of inEdgeIds) {
434
455
  const edge = graph.edges.get(eid);
435
- if (edge && adjLayerSet.has(edge.from)) {
436
- const neighbor = graph.nodes.get(edge.from);
437
- if (neighbor) {
438
- sum += neighbor.order;
439
- count++;
440
- }
456
+ if (!edge) continue;
457
+ const neighbor = graph.nodes.get(edge.from);
458
+ if (neighbor && neighbor.layer === adjLayerIndex) {
459
+ sum += neighbor.order;
460
+ count++;
441
461
  }
442
462
  }
443
463
  }
444
- } else {
464
+ bary[i] = count > 0 ? sum / count : i;
465
+ }
466
+ } else {
467
+ for (let i = 0; i < n; i++) {
468
+ const nodeId = layer[i];
469
+ let sum = 0;
470
+ let count = 0;
445
471
  const outEdgeIds = graph.outEdges.get(nodeId);
446
472
  if (outEdgeIds) {
447
473
  for (const eid of outEdgeIds) {
448
474
  const edge = graph.edges.get(eid);
449
- if (edge && adjLayerSet.has(edge.to)) {
450
- const neighbor = graph.nodes.get(edge.to);
451
- if (neighbor) {
452
- sum += neighbor.order;
453
- count++;
454
- }
475
+ if (!edge) continue;
476
+ const neighbor = graph.nodes.get(edge.to);
477
+ if (neighbor && neighbor.layer === adjLayerIndex) {
478
+ sum += neighbor.order;
479
+ count++;
455
480
  }
456
481
  }
457
482
  }
483
+ bary[i] = count > 0 ? sum / count : i;
458
484
  }
459
- barycenters.set(nodeId, count > 0 ? sum / count : i);
460
485
  }
461
- layer.sort((a, b) => barycenters.get(a) - barycenters.get(b));
462
- for (let i = 0; i < layer.length; i++) {
486
+ const indices = new Array(n);
487
+ for (let i = 0; i < n; i++) indices[i] = i;
488
+ indices.sort((a, b) => bary[a] - bary[b]);
489
+ const reordered = new Array(n);
490
+ for (let i = 0; i < n; i++) reordered[i] = layer[indices[i]];
491
+ for (let i = 0; i < n; i++) {
492
+ layer[i] = reordered[i];
463
493
  const node = graph.nodes.get(layer[i]);
464
494
  if (node) node.order = i;
465
495
  }
@@ -494,65 +524,65 @@ function transposeImprove(graph, layers, layerIndex) {
494
524
  }
495
525
  }
496
526
  }
527
+ var _scratchA = [];
528
+ var _scratchB = [];
497
529
  function countPairCrossings(graph, layers, layerIndex, nodeA, nodeB) {
498
530
  let crossings = 0;
499
531
  if (layerIndex > 0) {
500
- const upperLayer = new Set(layers[layerIndex - 1]);
501
- const predsA = [];
502
- const predsB = [];
532
+ const upperIdx = layerIndex - 1;
533
+ _scratchA.length = 0;
534
+ _scratchB.length = 0;
503
535
  const inA = graph.inEdges.get(nodeA);
504
536
  if (inA) {
505
537
  for (const eid of inA) {
506
538
  const edge = graph.edges.get(eid);
507
- if (edge && upperLayer.has(edge.from)) {
508
- const n = graph.nodes.get(edge.from);
509
- if (n) predsA.push(n.order);
510
- }
539
+ if (!edge) continue;
540
+ const n = graph.nodes.get(edge.from);
541
+ if (n && n.layer === upperIdx) _scratchA.push(n.order);
511
542
  }
512
543
  }
513
544
  const inB = graph.inEdges.get(nodeB);
514
545
  if (inB) {
515
546
  for (const eid of inB) {
516
547
  const edge = graph.edges.get(eid);
517
- if (edge && upperLayer.has(edge.from)) {
518
- const n = graph.nodes.get(edge.from);
519
- if (n) predsB.push(n.order);
520
- }
548
+ if (!edge) continue;
549
+ const n = graph.nodes.get(edge.from);
550
+ if (n && n.layer === upperIdx) _scratchB.push(n.order);
521
551
  }
522
552
  }
523
- for (const pA of predsA) {
524
- for (const pB of predsB) {
525
- if (pA > pB) crossings++;
553
+ for (let i = 0; i < _scratchA.length; i++) {
554
+ const pA = _scratchA[i];
555
+ for (let j = 0; j < _scratchB.length; j++) {
556
+ if (pA > _scratchB[j]) crossings++;
526
557
  }
527
558
  }
528
559
  }
529
560
  if (layerIndex < layers.length - 1) {
530
- const lowerLayer = new Set(layers[layerIndex + 1]);
531
- const succsA = [];
532
- const succsB = [];
561
+ const lowerIdx = layerIndex + 1;
562
+ _scratchA.length = 0;
563
+ _scratchB.length = 0;
533
564
  const outA = graph.outEdges.get(nodeA);
534
565
  if (outA) {
535
566
  for (const eid of outA) {
536
567
  const edge = graph.edges.get(eid);
537
- if (edge && lowerLayer.has(edge.to)) {
538
- const n = graph.nodes.get(edge.to);
539
- if (n) succsA.push(n.order);
540
- }
568
+ if (!edge) continue;
569
+ const n = graph.nodes.get(edge.to);
570
+ if (n && n.layer === lowerIdx) _scratchA.push(n.order);
541
571
  }
542
572
  }
543
573
  const outB = graph.outEdges.get(nodeB);
544
574
  if (outB) {
545
575
  for (const eid of outB) {
546
576
  const edge = graph.edges.get(eid);
547
- if (edge && lowerLayer.has(edge.to)) {
548
- const n = graph.nodes.get(edge.to);
549
- if (n) succsB.push(n.order);
550
- }
577
+ if (!edge) continue;
578
+ const n = graph.nodes.get(edge.to);
579
+ if (n && n.layer === lowerIdx) _scratchB.push(n.order);
551
580
  }
552
581
  }
553
- for (const sA of succsA) {
554
- for (const sB of succsB) {
555
- if (sA > sB) crossings++;
582
+ for (let i = 0; i < _scratchA.length; i++) {
583
+ const sA = _scratchA[i];
584
+ for (let j = 0; j < _scratchB.length; j++) {
585
+ if (sA > _scratchB[j]) crossings++;
556
586
  }
557
587
  }
558
588
  }
@@ -588,22 +618,36 @@ function countLayerCrossings(graph, upperLayer, lowerLayer) {
588
618
  return mergeSortCount(lowerPositions);
589
619
  }
590
620
  function mergeSortCount(arr) {
591
- if (arr.length <= 1) return 0;
592
- const mid = arr.length >> 1;
593
- const left = arr.slice(0, mid);
594
- const right = arr.slice(mid);
595
- let count = mergeSortCount(left) + mergeSortCount(right);
596
- let i = 0, j = 0, k = 0;
597
- while (i < left.length && j < right.length) {
598
- if (left[i] <= right[j]) {
599
- arr[k++] = left[i++];
600
- } else {
601
- count += left.length - i;
602
- arr[k++] = right[j++];
621
+ const n = arr.length;
622
+ if (n <= 1) return 0;
623
+ let buf = new Array(n);
624
+ let src = arr;
625
+ let dst = buf;
626
+ let count = 0;
627
+ for (let width = 1; width < n; width <<= 1) {
628
+ for (let i = 0; i < n; i += width << 1) {
629
+ const left = i;
630
+ const mid = Math.min(i + width, n);
631
+ const right = Math.min(i + (width << 1), n);
632
+ let a = left, b = mid, k = left;
633
+ while (a < mid && b < right) {
634
+ if (src[a] <= src[b]) {
635
+ dst[k++] = src[a++];
636
+ } else {
637
+ count += mid - a;
638
+ dst[k++] = src[b++];
639
+ }
640
+ }
641
+ while (a < mid) dst[k++] = src[a++];
642
+ while (b < right) dst[k++] = src[b++];
603
643
  }
644
+ const tmp = src;
645
+ src = dst;
646
+ dst = tmp;
647
+ }
648
+ if (src !== arr) {
649
+ for (let i = 0; i < n; i++) arr[i] = src[i];
604
650
  }
605
- while (i < left.length) arr[k++] = left[i++];
606
- while (j < right.length) arr[k++] = right[j++];
607
651
  return count;
608
652
  }
609
653
 
@@ -687,37 +731,69 @@ function optimizePositions(graph, layers, options, isHorizontal) {
687
731
  }
688
732
  centerAllLayers(graph, layers, isHorizontal);
689
733
  }
734
+ var _neighborBuf = new Float64Array(64);
735
+ var _desiredBuf = new Float64Array(64);
736
+ function ensureBufSize(size) {
737
+ if (_neighborBuf.length < size) _neighborBuf = new Float64Array(size * 2);
738
+ if (_desiredBuf.length < size) _desiredBuf = new Float64Array(size * 2);
739
+ }
690
740
  function optimizeLayer(graph, layer, options, isHorizontal) {
691
- if (layer.length === 0) return;
692
- const desired = /* @__PURE__ */ new Map();
693
- for (const nodeId of layer) {
741
+ const n = layer.length;
742
+ if (n === 0) return;
743
+ let maxDegree = 0;
744
+ for (let i = 0; i < n; i++) {
745
+ const id = layer[i];
746
+ const d = (graph.inEdges.get(id)?.size ?? 0) + (graph.outEdges.get(id)?.size ?? 0);
747
+ if (d > maxDegree) maxDegree = d;
748
+ }
749
+ ensureBufSize(Math.max(n, maxDegree));
750
+ const desired = _desiredBuf;
751
+ for (let i = 0; i < n; i++) {
752
+ const nodeId = layer[i];
694
753
  const node = graph.nodes.get(nodeId);
695
- const connected = [...graph.predecessors(nodeId), ...graph.successors(nodeId)];
696
- if (connected.length === 0) {
697
- desired.set(nodeId, getOrderPos(node, isHorizontal));
754
+ let nbCount = 0;
755
+ const inEdgeIds = graph.inEdges.get(nodeId);
756
+ if (inEdgeIds) {
757
+ for (const eid of inEdgeIds) {
758
+ const edge = graph.edges.get(eid);
759
+ if (!edge) continue;
760
+ const c = graph.nodes.get(edge.from);
761
+ if (!c) continue;
762
+ _neighborBuf[nbCount++] = getOrderPos(c, isHorizontal) + getOrderSize(c, isHorizontal) / 2;
763
+ }
764
+ }
765
+ const outEdgeIds = graph.outEdges.get(nodeId);
766
+ if (outEdgeIds) {
767
+ for (const eid of outEdgeIds) {
768
+ const edge = graph.edges.get(eid);
769
+ if (!edge) continue;
770
+ const c = graph.nodes.get(edge.to);
771
+ if (!c) continue;
772
+ _neighborBuf[nbCount++] = getOrderPos(c, isHorizontal) + getOrderSize(c, isHorizontal) / 2;
773
+ }
774
+ }
775
+ if (nbCount === 0) {
776
+ desired[i] = getOrderPos(node, isHorizontal);
698
777
  continue;
699
778
  }
700
- const positions = connected.map((cId) => {
701
- const c = graph.nodes.get(cId);
702
- return getOrderPos(c, isHorizontal) + getOrderSize(c, isHorizontal) / 2;
703
- }).sort((a, b) => a - b);
779
+ sortPrefix(_neighborBuf, nbCount);
704
780
  const nodeSize = getOrderSize(node, isHorizontal);
705
- const median = positions.length % 2 === 0 ? (positions[positions.length / 2 - 1] + positions[positions.length / 2]) / 2 : positions[Math.floor(positions.length / 2)];
706
- desired.set(nodeId, median - nodeSize / 2);
781
+ const median = (nbCount & 1) === 0 ? (_neighborBuf[(nbCount >> 1) - 1] + _neighborBuf[nbCount >> 1]) / 2 : _neighborBuf[nbCount >> 1];
782
+ desired[i] = median - nodeSize / 2;
707
783
  }
708
- for (let i = 0; i < layer.length; i++) {
784
+ for (let i = 0; i < n; i++) {
709
785
  const nodeId = layer[i];
710
786
  const node = graph.nodes.get(nodeId);
711
- let desiredPos = desired.get(nodeId);
787
+ let desiredPos = desired[i];
712
788
  if (i > 0) {
713
789
  const prevId = layer[i - 1];
714
790
  const prev = graph.nodes.get(prevId);
715
791
  const prevEnd = getOrderPos(prev, isHorizontal) + getOrderSize(prev, isHorizontal);
716
- desiredPos = Math.max(desiredPos, prevEnd + options.nodeSpacing);
792
+ if (desiredPos < prevEnd + options.nodeSpacing) desiredPos = prevEnd + options.nodeSpacing;
717
793
  }
718
794
  setOrderPos(node, isHorizontal, desiredPos);
719
795
  }
720
- for (let i = layer.length - 2; i >= 0; i--) {
796
+ for (let i = n - 2; i >= 0; i--) {
721
797
  const nodeId = layer[i];
722
798
  const node = graph.nodes.get(nodeId);
723
799
  const nextId = layer[i + 1];
@@ -729,6 +805,17 @@ function optimizeLayer(graph, layer, options, isHorizontal) {
729
805
  }
730
806
  }
731
807
  }
808
+ function sortPrefix(arr, count) {
809
+ for (let i = 1; i < count; i++) {
810
+ const x = arr[i];
811
+ let j = i - 1;
812
+ while (j >= 0 && arr[j] > x) {
813
+ arr[j + 1] = arr[j];
814
+ j--;
815
+ }
816
+ arr[j + 1] = x;
817
+ }
818
+ }
732
819
  function centerAllLayers(graph, layers, isHorizontal) {
733
820
  if (layers.length === 0) return;
734
821
  let globalMin = Infinity;
@@ -1381,6 +1468,156 @@ function score(handles, expected) {
1381
1468
  }
1382
1469
  return { matched, total, matchRatio: total === 0 ? 1 : matched / total };
1383
1470
  }
1471
+ function applyRelocateProposals(input, preview, options) {
1472
+ if (!options.onProposal) return input;
1473
+ const previewIndex = new Map(preview.nodes.map((n) => [n.id, n]));
1474
+ const inputIndex = new Map(input.nodes.map((n) => [n.id, n]));
1475
+ const newNodes = input.nodes.map((node) => {
1476
+ const proposal = computeRelocateProposal(node, input.edges, inputIndex, previewIndex, options.direction);
1477
+ if (!proposal) return node;
1478
+ const accepted = options.onProposal(proposal);
1479
+ return accepted ?? node;
1480
+ });
1481
+ return { nodes: newNodes, edges: input.edges };
1482
+ }
1483
+ function computeRelocateProposal(node, edges, inputIndex, previewIndex, direction) {
1484
+ const selfPreview = previewIndex.get(node.id);
1485
+ if (!selfPreview) return null;
1486
+ if (node.handles.length === 0) return null;
1487
+ const isHorizontal = direction === "LR" || direction === "RL";
1488
+ const rankMin = isHorizontal ? selfPreview.x : selfPreview.y;
1489
+ const rankMax = isHorizontal ? selfPreview.x + selfPreview.width : selfPreview.y + selfPreview.height;
1490
+ const orderMin = isHorizontal ? selfPreview.y : selfPreview.x;
1491
+ const orderMax = isHorizontal ? selfPreview.y + selfPreview.height : selfPreview.x + selfPreview.width;
1492
+ const orderCenter = (orderMin + orderMax) / 2;
1493
+ const usedHandleIds = /* @__PURE__ */ new Set();
1494
+ for (const e of edges) {
1495
+ if (e.from === node.id) usedHandleIds.add(e.fromHandle);
1496
+ if (e.to === node.id) usedHandleIds.add(e.toHandle);
1497
+ }
1498
+ if (usedHandleIds.size === 0) return null;
1499
+ const votesByHandle = /* @__PURE__ */ new Map();
1500
+ for (const e of edges) {
1501
+ const isFrom = e.from === node.id;
1502
+ const isTo = e.to === node.id;
1503
+ if (!isFrom && !isTo) continue;
1504
+ const handleId = isFrom ? e.fromHandle : e.toHandle;
1505
+ const neighborId = isFrom ? e.to : e.from;
1506
+ const np = previewIndex.get(neighborId);
1507
+ if (!np) continue;
1508
+ const nbrRankMin = isHorizontal ? np.x : np.y;
1509
+ const nbrRankMax = isHorizontal ? np.x + np.width : np.y + np.height;
1510
+ const overlap = Math.min(rankMax, nbrRankMax) - Math.max(rankMin, nbrRankMin);
1511
+ const isFlow = overlap <= 0;
1512
+ const nbrOrderC = isHorizontal ? np.y + np.height / 2 : np.x + np.width / 2;
1513
+ const nbrRankC = isHorizontal ? np.x + np.width / 2 : np.y + np.height / 2;
1514
+ const selfRankC = isHorizontal ? selfPreview.x + selfPreview.width / 2 : selfPreview.y + selfPreview.height / 2;
1515
+ const nbrIsBefore = nbrRankMax <= rankMin || overlap <= 0 && nbrRankC < selfRankC;
1516
+ const flowSide = isHorizontal ? nbrIsBefore ? "left" : "right" : nbrIsBefore ? "top" : "bottom";
1517
+ const d = nbrOrderC - orderCenter;
1518
+ const perpSide = isHorizontal ? d >= 0 ? "bottom" : "top" : d >= 0 ? "right" : "left";
1519
+ const beyond = nbrOrderC > orderMax ? 1 : nbrOrderC < orderMin ? -1 : 0;
1520
+ const dist = Math.hypot(nbrRankC - selfRankC, nbrOrderC - orderCenter);
1521
+ const weight = dist > 0 ? 1 / dist : 1;
1522
+ const arr = votesByHandle.get(handleId) ?? [];
1523
+ arr.push({ weight, isFlow, flowSide, perpSide, orderCoord: nbrOrderC, rankCoord: nbrRankC, beyond });
1524
+ votesByHandle.set(handleId, arr);
1525
+ }
1526
+ const resolved = /* @__PURE__ */ new Map();
1527
+ for (const [handleId, votes] of votesByHandle) {
1528
+ if (votes.length === 0) continue;
1529
+ const tallies = { top: 0, right: 0, bottom: 0, left: 0 };
1530
+ let dom = votes[0];
1531
+ for (const v of votes) {
1532
+ tallies[v.isFlow ? v.flowSide : v.perpSide] += v.weight;
1533
+ if (v.weight > dom.weight) dom = v;
1534
+ }
1535
+ const side = Object.entries(tallies).reduce((acc, cur) => cur[1] > acc[1] ? cur : acc, ["top", -Infinity])[0];
1536
+ resolved.set(handleId, {
1537
+ side,
1538
+ isFlow: dom.isFlow,
1539
+ flowSide: dom.flowSide,
1540
+ perpSide: dom.perpSide,
1541
+ orderCoord: dom.orderCoord,
1542
+ rankCoord: dom.rankCoord,
1543
+ beyond: dom.beyond
1544
+ });
1545
+ }
1546
+ for (const flowSide of ["top", "bottom", "left", "right"]) {
1547
+ const group = [...resolved.entries()].filter(([, r]) => r.isFlow && r.side === flowSide);
1548
+ if (group.length < 2) continue;
1549
+ let hasLeft = false, hasRight = false;
1550
+ for (const [, r] of group) {
1551
+ if (r.orderCoord < orderCenter) hasLeft = true;
1552
+ else if (r.orderCoord > orderCenter) hasRight = true;
1553
+ }
1554
+ if (hasLeft && hasRight) continue;
1555
+ const distOf = (oc) => oc > orderMax ? oc - orderMax : orderMin - oc;
1556
+ const distinctCoords = [...new Set(group.filter(([, r]) => r.beyond !== 0).map(([, r]) => r.orderCoord))].sort((a, b) => distOf(a) - distOf(b));
1557
+ if (distinctCoords.length >= 2) {
1558
+ const keep = distinctCoords[0];
1559
+ for (const [, r] of group) {
1560
+ if (r.beyond !== 0 && r.orderCoord !== keep) r.side = r.perpSide;
1561
+ }
1562
+ }
1563
+ }
1564
+ const sideChanges = {};
1565
+ const newSideById = /* @__PURE__ */ new Map();
1566
+ for (const h of node.handles) {
1567
+ const r = resolved.get(h.id);
1568
+ if (!r) continue;
1569
+ newSideById.set(h.id, r.side);
1570
+ if (r.side !== h.position) sideChanges[h.id] = { from: h.position, to: r.side };
1571
+ }
1572
+ const newOffsetById = /* @__PURE__ */ new Map();
1573
+ const bySide = /* @__PURE__ */ new Map();
1574
+ for (const [hid, r] of resolved) {
1575
+ const arr = bySide.get(r.side) ?? [];
1576
+ arr.push(hid);
1577
+ bySide.set(r.side, arr);
1578
+ }
1579
+ for (const [side, handleIds] of bySide) {
1580
+ if (handleIds.length < 2) continue;
1581
+ const offsets = handleIds.map((hid) => node.handles.find((h) => h.id === hid).offset ?? 0.5).sort((a, b) => a - b);
1582
+ const horizontalSide = side === "top" || side === "bottom";
1583
+ const sortKey = (hid) => {
1584
+ const r = resolved.get(hid);
1585
+ if (isHorizontal) {
1586
+ return horizontalSide ? r.rankCoord : r.orderCoord;
1587
+ }
1588
+ return horizontalSide ? r.orderCoord : r.rankCoord;
1589
+ };
1590
+ const sortedHandles = [...handleIds].sort((a, b) => {
1591
+ const ra = sortKey(a), rb = sortKey(b);
1592
+ return ra - rb || (a < b ? -1 : a > b ? 1 : 0);
1593
+ });
1594
+ for (let i = 0; i < sortedHandles.length; i++) {
1595
+ newOffsetById.set(sortedHandles[i], offsets[i]);
1596
+ }
1597
+ }
1598
+ let changed = false;
1599
+ const newHandles = node.handles.map((h) => {
1600
+ const side = newSideById.get(h.id) ?? h.position;
1601
+ const offset = newOffsetById.has(h.id) ? newOffsetById.get(h.id) : h.offset ?? 0.5;
1602
+ if (side !== h.position || offset !== (h.offset ?? 0.5)) {
1603
+ changed = true;
1604
+ return { ...h, position: side, offset };
1605
+ }
1606
+ return h;
1607
+ });
1608
+ if (!changed) return null;
1609
+ const proposed = { ...node, handles: newHandles };
1610
+ const sideSummary = Object.entries(sideChanges).map(([id, c]) => `${id}: ${c.from}\u2192${c.to}`).join(", ");
1611
+ const reason = sideSummary ? `${Object.keys(sideChanges).length} handle(s) repositioned toward their neighbor (${sideSummary})` : `handles on a shared side reordered to match neighbor order`;
1612
+ return {
1613
+ type: "relocate-handles",
1614
+ nodeId: node.id,
1615
+ current: node,
1616
+ proposed,
1617
+ changes: sideChanges,
1618
+ reason
1619
+ };
1620
+ }
1384
1621
  function rotateHandles(handles, rot) {
1385
1622
  const rotateSide = (s) => {
1386
1623
  if (rot === 180) {
@@ -1399,7 +1636,12 @@ var COMPOUND_HEADER = 28;
1399
1636
  function layout(input, options) {
1400
1637
  const resolved = resolveOptions(options);
1401
1638
  if (input.nodes.length === 0) return { nodes: [], edges: [] };
1402
- const adjusted = applyRotationProposals(input, resolved);
1639
+ let adjusted = applyRotationProposals(input, resolved);
1640
+ if (resolved.onProposal) {
1641
+ const previewOptions = { ...resolved, onProposal: void 0 };
1642
+ const preview = layoutCompound(adjusted, previewOptions);
1643
+ adjusted = applyRelocateProposals(adjusted, preview, resolved);
1644
+ }
1403
1645
  return layoutCompound(adjusted, resolved);
1404
1646
  }
1405
1647
  function computeLayout(graph, options) {
@@ -1408,7 +1650,12 @@ function computeLayout(graph, options) {
1408
1650
  let layers = assignLayers(graph);
1409
1651
  layers = insertDummyNodes(graph, layers);
1410
1652
  let rail = railLayers(layers, graph);
1411
- rail = minimizeCrossings(graph, rail, options.crossingMinimizationIterations);
1653
+ rail = minimizeCrossings(
1654
+ graph,
1655
+ rail,
1656
+ options.crossingMinimizationIterations,
1657
+ options.skipTranspose
1658
+ );
1412
1659
  assignCoordinates(graph, rail, options);
1413
1660
  placeValueSidecars(graph, layers, options);
1414
1661
  const routedEdges = routeEdges(graph, options.direction, options.edgeMargin);