@bpmnkit/core 0.0.22 → 0.0.23

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.
@@ -246,6 +246,7 @@ function countForwardReachable(startId, dag) {
246
246
  * Align nodes in linear sequences to a common y-baseline.
247
247
  * A "linear" node has ≤1 predecessor and ≤1 successor, and is not a gateway.
248
248
  * Walks forward from each chain root, setting successors to the same center-y.
249
+ * Crosses split/join gateway pairs to align the full branch spine.
249
250
  */
250
251
  export function alignBranchBaselines(layoutNodes, dag) {
251
252
  const nodeMap = new Map();
@@ -288,8 +289,36 @@ export function alignBranchBaselines(layoutNodes, dag) {
288
289
  const current = nodeMap.get(currentId);
289
290
  if (!current)
290
291
  break;
291
- if (GATEWAY_TYPE_SET.has(current.type))
292
+ if (GATEWAY_TYPE_SET.has(current.type)) {
293
+ // If this is a split gateway, align it, find its join, align that, continue after
294
+ const trueSuccs = dag.successors.get(currentId) ?? [];
295
+ if (trueSuccs.length >= 2) {
296
+ const dy = baselineCenterY - (current.bounds.y + current.bounds.height / 2);
297
+ if (Math.abs(dy) > 0.5) {
298
+ current.bounds.y += dy;
299
+ if (current.labelBounds)
300
+ current.labelBounds.y += dy;
301
+ }
302
+ const joinId = findJoinGateway(currentId, dag, nodeMap);
303
+ if (joinId && !visited.has(joinId)) {
304
+ const joinNode = nodeMap.get(joinId);
305
+ if (joinNode) {
306
+ visited.add(joinId);
307
+ const jdy = baselineCenterY - (joinNode.bounds.y + joinNode.bounds.height / 2);
308
+ if (Math.abs(jdy) > 0.5) {
309
+ joinNode.bounds.y += jdy;
310
+ if (joinNode.labelBounds)
311
+ joinNode.labelBounds.y += jdy;
312
+ }
313
+ // Continue after the join
314
+ const joinSuccs = dag.successors.get(joinId) ?? [];
315
+ currentId = joinSuccs.length === 1 ? joinSuccs[0] : undefined;
316
+ continue;
317
+ }
318
+ }
319
+ }
292
320
  break;
321
+ }
293
322
  const dy = baselineCenterY - (current.bounds.y + current.bounds.height / 2);
294
323
  if (Math.abs(dy) > 0.5) {
295
324
  current.bounds.y += dy;
@@ -302,8 +331,6 @@ export function alignBranchBaselines(layoutNodes, dag) {
302
331
  nextId = succs[0];
303
332
  }
304
333
  else if (succs.length > 1 && !GATEWAY_TYPE_SET.has(current.type)) {
305
- // Non-gateway with multiple successors (back-edge reversal artifact).
306
- // Follow the unique-predecessor successor — the main flow continuation.
307
334
  nextId = succs.find((s) => (dag.predecessors.get(s) ?? []).length === 1);
308
335
  }
309
336
  else {
@@ -312,10 +339,10 @@ export function alignBranchBaselines(layoutNodes, dag) {
312
339
  if (!nextId)
313
340
  break;
314
341
  const nextNode = nodeMap.get(nextId);
315
- if (!nextNode || GATEWAY_TYPE_SET.has(nextNode.type))
342
+ if (!nextNode)
316
343
  break;
317
344
  const nextPreds = dag.predecessors.get(nextId) ?? [];
318
- if (nextPreds.length !== 1)
345
+ if (nextPreds.length !== 1 && !GATEWAY_TYPE_SET.has(nextNode.type))
319
346
  break;
320
347
  currentId = nextId;
321
348
  }
@@ -526,19 +553,10 @@ export function findBaselinePath(layoutNodes, dag, backEdges = []) {
526
553
  // True split gateway: find join and jump to it
527
554
  const joinId = findJoinGateway(currentId, dag, nodeMap);
528
555
  if (joinId) {
529
- // If the join is a direct successor (bypass edge exists), follow the longer
530
- // task-bearing branch instead of jumping over it. This keeps task nodes on
531
- // the baseline so they stay flat (same y as the gateways).
532
- if (trueSuccs.includes(joinId)) {
533
- const nonBypass = trueSuccs.filter((s) => s !== joinId);
534
- currentId =
535
- nonBypass.length === 1
536
- ? nonBypass[0]
537
- : (findContinuationSuccessor(nonBypass, dag, nodeMap, visited) ?? joinId);
538
- }
539
- else {
540
- currentId = joinId;
541
- }
556
+ // Always jump directly to the join gateway. Task-bearing branches are
557
+ // off-baseline and distributed by distributeSplitBranches, so the direct
558
+ // bypass edge never routes through task nodes.
559
+ currentId = joinId;
542
560
  }
543
561
  else {
544
562
  currentId = findContinuationSuccessor(trueSuccs, dag, nodeMap, visited);
@@ -653,34 +671,306 @@ export function distributeSplitBranches(layoutNodes, dag, backEdges = []) {
653
671
  continue;
654
672
  splitGateways.push({ node: n, succs: trueSuccs, branchStarts, joinId });
655
673
  }
656
- // Pass 1: multi-branch gateways (distribute symmetrically)
657
- for (const { node: n, branchStarts, joinId } of splitGateways) {
658
- if (branchStarts.length < 2)
659
- continue;
674
+ // Sort deepest-first so child gateway branches are distributed before
675
+ // parent gateways compute branch heights (avoids underestimating sub-branch area).
676
+ splitGateways.sort((a, b) => b.node.layer - a.node.layer);
677
+ // Process all gateways deepest-first (single loop, handles both multi- and single-branch)
678
+ for (const { node: n, succs, branchStarts, joinId } of splitGateways) {
660
679
  const gatewayCY = n.bounds.y + n.bounds.height / 2;
661
- branchStarts.sort((a, b) => {
662
- const na = nodeMap.get(a);
663
- const nb = nodeMap.get(b);
664
- if (!na || !nb)
665
- return 0;
666
- return na.bounds.y + na.bounds.height / 2 - (nb.bounds.y + nb.bounds.height / 2);
667
- });
668
- const count = branchStarts.length;
669
- const spacing = GRID_CELL_HEIGHT;
670
- const startOffset = -((count - 1) / 2) * spacing;
671
- for (let i = 0; i < count; i++) {
672
- const branchId = branchStarts[i];
680
+ // Collect baseline obstacles (actual element bboxes) for 2D collision detection.
681
+ // Unlike the old corridor approach, this only pushes branches past elements
682
+ // that actually overlap in both X and Y — not past the entire baseline extent.
683
+ const branchSet = new Set(branchStarts);
684
+ const baselineSucc = succs.find((s) => !branchSet.has(s));
685
+ const baselineObstacles = [];
686
+ if (baselineSucc) {
687
+ for (const bid of collectBranchChain(baselineSucc, dag, joinId)) {
688
+ const bnode = nodeMap.get(bid);
689
+ if (!bnode)
690
+ continue;
691
+ const bottom = bnode.labelBounds
692
+ ? Math.max(bnode.bounds.y + bnode.bounds.height, bnode.labelBounds.y + bnode.labelBounds.height)
693
+ : bnode.bounds.y + bnode.bounds.height;
694
+ baselineObstacles.push({
695
+ x: bnode.bounds.x,
696
+ y: bnode.bounds.y,
697
+ right: bnode.bounds.x + bnode.bounds.width,
698
+ bottom,
699
+ });
700
+ }
701
+ }
702
+ const yMargin = GRID_CELL_HEIGHT / 2;
703
+ const xMargin = GRID_CELL_WIDTH / 2;
704
+ if (branchStarts.length >= 2) {
705
+ // Multi-branch: distribute symmetrically, heaviest branch at center
706
+ const sorted = [...branchStarts].sort((a, b) => collectBranchChain(b, dag, joinId).length - collectBranchChain(a, dag, joinId).length);
707
+ const count = sorted.length;
708
+ const positioned = new Array(count);
709
+ const m = Math.floor((count - 1) / 2);
710
+ // biome-ignore lint/style/noNonNullAssertion: sorted is non-empty (count >= 2)
711
+ positioned[m] = sorted[0];
712
+ let above = m - 1;
713
+ let below = m + 1;
714
+ for (let si = 1; si < count;) {
715
+ // biome-ignore lint/style/noNonNullAssertion: si < count ensures element exists
716
+ if (below < count && si < count)
717
+ positioned[below++] = sorted[si++];
718
+ // biome-ignore lint/style/noNonNullAssertion: si < count ensures element exists
719
+ if (above >= 0 && si < count)
720
+ positioned[above--] = sorted[si++];
721
+ }
722
+ // Compute anchor-relative extents and X range per branch
723
+ // Uses backbone extent for stacking height (initial placement),
724
+ // and full subtree extent for collision detection (safety validation).
725
+ const branchInfo = [];
726
+ for (let i = 0; i < count; i++) {
727
+ const branchId = positioned[i];
728
+ if (!branchId) {
729
+ branchInfo.push(null);
730
+ continue;
731
+ }
732
+ const chain = collectBranchChain(branchId, dag, joinId);
733
+ const startNode = nodeMap.get(branchId);
734
+ if (!startNode) {
735
+ branchInfo.push(null);
736
+ continue;
737
+ }
738
+ const startCY = startNode.bounds.y + startNode.bounds.height / 2;
739
+ let minY = Number.POSITIVE_INFINITY;
740
+ let maxY = Number.NEGATIVE_INFINITY;
741
+ let minX = Number.POSITIVE_INFINITY;
742
+ let maxX = Number.NEGATIVE_INFINITY;
743
+ for (const bid of chain) {
744
+ if (baselineSet.has(bid))
745
+ continue;
746
+ const bnode = nodeMap.get(bid);
747
+ if (!bnode)
748
+ continue;
749
+ minY = Math.min(minY, bnode.bounds.y);
750
+ maxY = Math.max(maxY, bnode.bounds.y + bnode.bounds.height);
751
+ if (bnode.labelBounds)
752
+ maxY = Math.max(maxY, bnode.labelBounds.y + bnode.labelBounds.height);
753
+ minX = Math.min(minX, bnode.bounds.x);
754
+ maxX = Math.max(maxX, bnode.bounds.x + bnode.bounds.width);
755
+ }
756
+ const height = minY < maxY ? Math.max(maxY - minY, GRID_CELL_HEIGHT) : GRID_CELL_HEIGHT;
757
+ // Backbone extent for stacking
758
+ const backbone = collectBranchBackbone(branchId, dag, joinId, nodeMap);
759
+ let bbMinY = Number.POSITIVE_INFINITY;
760
+ let bbMaxY = Number.NEGATIVE_INFINITY;
761
+ for (const bid of backbone) {
762
+ if (baselineSet.has(bid))
763
+ continue;
764
+ const bnode = nodeMap.get(bid);
765
+ if (!bnode)
766
+ continue;
767
+ bbMinY = Math.min(bbMinY, bnode.bounds.y);
768
+ bbMaxY = Math.max(bbMaxY, bnode.bounds.y + bnode.bounds.height);
769
+ if (bnode.labelBounds)
770
+ bbMaxY = Math.max(bbMaxY, bnode.labelBounds.y + bnode.labelBounds.height);
771
+ }
772
+ const backboneHeight = bbMinY < bbMaxY ? Math.max(bbMaxY - bbMinY, GRID_CELL_HEIGHT) : height;
773
+ branchInfo.push({
774
+ chain,
775
+ extAbove: minY < Number.POSITIVE_INFINITY ? startCY - minY : height / 2,
776
+ extBelow: maxY > Number.NEGATIVE_INFINITY ? maxY - startCY : height / 2,
777
+ backboneHeight,
778
+ height,
779
+ minX: minX < Number.POSITIVE_INFINITY ? minX : startNode.bounds.x,
780
+ maxX: maxX > Number.NEGATIVE_INFINITY ? maxX : startNode.bounds.x + startNode.bounds.width,
781
+ });
782
+ }
783
+ const minSpacing = GRID_CELL_HEIGHT / 2;
784
+ // Use backbone height for initial stacking (tighter spacing)
785
+ let totalH = 0;
786
+ for (let i = 0; i < count; i++) {
787
+ totalH += branchInfo[i]?.backboneHeight ?? GRID_CELL_HEIGHT;
788
+ if (i < count - 1)
789
+ totalH += minSpacing;
790
+ }
791
+ let currentTop = gatewayCY - totalH / 2;
792
+ // Frontier tracking: placed branch extents for branch-to-branch spacing
793
+ let aboveFrontierY = gatewayCY;
794
+ let belowFrontierY = gatewayCY;
795
+ for (let i = 0; i < count; i++) {
796
+ const info = branchInfo[i];
797
+ if (!info) {
798
+ currentTop += GRID_CELL_HEIGHT + minSpacing;
799
+ continue;
800
+ }
801
+ const branchId = positioned[i];
802
+ if (!branchId)
803
+ continue;
804
+ const branchNode = nodeMap.get(branchId);
805
+ if (!branchNode)
806
+ continue;
807
+ // Use backbone height for initial placement, full extent for collision
808
+ const bh = info.backboneHeight;
809
+ let targetCY = currentTop + bh / 2;
810
+ // Resolve 2D collisions with baseline obstacles (full extent)
811
+ targetCY = resolveBranchObstacles(targetCY, info.extAbove, info.extBelow, info.minX, info.maxX, gatewayCY, baselineObstacles, yMargin, xMargin);
812
+ // Enforce frontier: prevent branch-to-branch overlap (full extent)
813
+ if (targetCY >= gatewayCY) {
814
+ const newTop = targetCY - info.extAbove;
815
+ if (newTop < belowFrontierY + minSpacing) {
816
+ targetCY = belowFrontierY + minSpacing + info.extAbove;
817
+ }
818
+ belowFrontierY = targetCY + info.extBelow;
819
+ }
820
+ else {
821
+ const newBottom = targetCY + info.extBelow;
822
+ if (newBottom > aboveFrontierY - minSpacing) {
823
+ targetCY = aboveFrontierY - minSpacing - info.extBelow;
824
+ }
825
+ aboveFrontierY = targetCY - info.extAbove;
826
+ }
827
+ const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
828
+ const dy = targetCY - currentCY;
829
+ if (Math.abs(dy) > 0.5) {
830
+ for (const bid of info.chain) {
831
+ if (baselineSet.has(bid))
832
+ continue;
833
+ const bnode = nodeMap.get(bid);
834
+ if (!bnode)
835
+ continue;
836
+ bnode.bounds.y += dy;
837
+ if (bnode.labelBounds)
838
+ bnode.labelBounds.y += dy;
839
+ }
840
+ }
841
+ currentTop += bh + minSpacing;
842
+ }
843
+ }
844
+ else {
845
+ // Single-branch: collision-based offset with peer-aware gap enforcement
846
+ const branchId = branchStarts[0];
673
847
  if (!branchId)
674
848
  continue;
675
849
  const branchNode = nodeMap.get(branchId);
676
850
  if (!branchNode)
677
851
  continue;
678
- const targetCY = gatewayCY + startOffset + i * spacing;
852
+ const chain = collectBranchChain(branchId, dag, joinId);
853
+ // Compute full subtree extents (for collision validation)
854
+ let branchMinY = Number.POSITIVE_INFINITY;
855
+ let branchMaxY = Number.NEGATIVE_INFINITY;
856
+ let branchMinX = Number.POSITIVE_INFINITY;
857
+ let branchMaxX = Number.NEGATIVE_INFINITY;
858
+ for (const bid of chain) {
859
+ if (baselineSet.has(bid))
860
+ continue;
861
+ const bnode = nodeMap.get(bid);
862
+ if (!bnode)
863
+ continue;
864
+ branchMinY = Math.min(branchMinY, bnode.bounds.y);
865
+ branchMaxY = Math.max(branchMaxY, bnode.bounds.y + bnode.bounds.height);
866
+ if (bnode.labelBounds) {
867
+ branchMaxY = Math.max(branchMaxY, bnode.labelBounds.y + bnode.labelBounds.height);
868
+ }
869
+ branchMinX = Math.min(branchMinX, bnode.bounds.x);
870
+ branchMaxX = Math.max(branchMaxX, bnode.bounds.x + bnode.bounds.width);
871
+ }
679
872
  const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
873
+ const extAbove = branchMinY < Number.POSITIVE_INFINITY ? currentCY - branchMinY : GRID_CELL_HEIGHT / 2;
874
+ const extBelow = branchMaxY > Number.NEGATIVE_INFINITY ? branchMaxY - currentCY : GRID_CELL_HEIGHT / 2;
875
+ if (branchMinX === Number.POSITIVE_INFINITY)
876
+ branchMinX = branchNode.bounds.x;
877
+ if (branchMaxX === Number.NEGATIVE_INFINITY)
878
+ branchMaxX = branchNode.bounds.x + branchNode.bounds.width;
879
+ // Compute backbone extent (spine only, skipping sub-gateway branches)
880
+ // for a tighter initial offset that places the branch closer to baseline.
881
+ const backbone = collectBranchBackbone(branchId, dag, joinId, nodeMap);
882
+ let bbMinY = Number.POSITIVE_INFINITY;
883
+ let bbMaxY = Number.NEGATIVE_INFINITY;
884
+ for (const bid of backbone) {
885
+ if (baselineSet.has(bid))
886
+ continue;
887
+ const bnode = nodeMap.get(bid);
888
+ if (!bnode)
889
+ continue;
890
+ bbMinY = Math.min(bbMinY, bnode.bounds.y);
891
+ bbMaxY = Math.max(bbMaxY, bnode.bounds.y + bnode.bounds.height);
892
+ if (bnode.labelBounds) {
893
+ bbMaxY = Math.max(bbMaxY, bnode.labelBounds.y + bnode.labelBounds.height);
894
+ }
895
+ }
896
+ const bbExtAbove = bbMinY < Number.POSITIVE_INFINITY ? currentCY - bbMinY : extAbove;
897
+ const bbExtBelow = bbMaxY > Number.NEGATIVE_INFINITY ? bbMaxY - currentCY : extBelow;
898
+ // Use backbone extent for initial offset (closer to baseline)
899
+ const basicMinOffset = Math.max(GRID_CELL_HEIGHT, bbExtAbove + bbExtBelow + n.bounds.height / 2 + 20);
900
+ const direction = currentCY < gatewayCY ? -1 : 1;
901
+ let targetCY = gatewayCY + direction * basicMinOffset;
902
+ // Validate using per-element collision against baseline obstacles.
903
+ // Only backbone elements checked — sub-branches at different X
904
+ // positions don't constrain placement.
905
+ for (const obs of baselineObstacles) {
906
+ for (const bid of backbone) {
907
+ if (baselineSet.has(bid))
908
+ continue;
909
+ const bnode = nodeMap.get(bid);
910
+ if (!bnode)
911
+ continue;
912
+ const elemRight = bnode.bounds.x + bnode.bounds.width;
913
+ if (elemRight + xMargin < obs.x || bnode.bounds.x - xMargin > obs.right)
914
+ continue;
915
+ if (direction === 1) {
916
+ const elemNewY = bnode.bounds.y + (targetCY - currentCY);
917
+ if (elemNewY < obs.bottom + yMargin) {
918
+ const needed = currentCY + obs.bottom + yMargin - bnode.bounds.y;
919
+ if (needed > targetCY)
920
+ targetCY = needed;
921
+ }
922
+ }
923
+ else {
924
+ const elemNewBottom = bnode.bounds.y + bnode.bounds.height + (targetCY - currentCY);
925
+ if (elemNewBottom > obs.y - yMargin) {
926
+ const needed = currentCY + obs.y - yMargin - bnode.bounds.y - bnode.bounds.height;
927
+ if (needed < targetCY)
928
+ targetCY = needed;
929
+ }
930
+ }
931
+ }
932
+ }
933
+ // Peer-aware gap enforcement — only backbone elements checked against
934
+ // same-layer peers. Sub-branch elements from nested gateways are
935
+ // handled by the nested gateway's own distribution step.
936
+ const chainSet = new Set(chain);
937
+ const backboneSet = new Set(backbone);
938
+ const minGap = GRID_CELL_HEIGHT / 2;
939
+ const initialDy = targetCY - currentCY;
940
+ let extraDy = 0;
941
+ for (const chainNodeId of backbone) {
942
+ if (baselineSet.has(chainNodeId))
943
+ continue;
944
+ const chainNode = nodeMap.get(chainNodeId);
945
+ if (!chainNode)
946
+ continue;
947
+ const chainNodeCY = chainNode.bounds.y + chainNode.bounds.height / 2;
948
+ const chainNodeNewCY = chainNodeCY + initialDy;
949
+ for (const peer of layoutNodes) {
950
+ if (peer.layer !== chainNode.layer || chainSet.has(peer.id))
951
+ continue;
952
+ const peerCY = peer.bounds.y + peer.bounds.height / 2;
953
+ const minDist = (chainNode.bounds.height + peer.bounds.height) / 2 + minGap;
954
+ if (Math.abs(chainNodeNewCY + extraDy - peerCY) < minDist) {
955
+ if (direction === -1) {
956
+ const needed = peerCY - minDist - chainNodeNewCY;
957
+ if (needed < extraDy)
958
+ extraDy = needed;
959
+ }
960
+ else {
961
+ const needed = peerCY + minDist - chainNodeNewCY;
962
+ if (needed > extraDy)
963
+ extraDy = needed;
964
+ }
965
+ }
966
+ }
967
+ }
968
+ targetCY += extraDy;
680
969
  const dy = targetCY - currentCY;
681
970
  if (Math.abs(dy) > 0.5) {
682
- const chain = collectBranchChain(branchId, dag, joinId);
683
971
  for (const bid of chain) {
972
+ if (baselineSet.has(bid))
973
+ continue;
684
974
  const bnode = nodeMap.get(bid);
685
975
  if (!bnode)
686
976
  continue;
@@ -691,60 +981,256 @@ export function distributeSplitBranches(layoutNodes, dag, backEdges = []) {
691
981
  }
692
982
  }
693
983
  }
694
- // Pass 2: single-branch gateways (peer-aware gap enforcement)
695
- for (const { node: n, branchStarts, joinId } of splitGateways) {
696
- if (branchStarts.length !== 1)
984
+ }
985
+ /**
986
+ * Push targetCY away from gatewayCY until it no longer overlaps any obstacle in both X and Y.
987
+ * Only elements that overlap the branch's X range (with margin) trigger a push.
988
+ */
989
+ function resolveBranchObstacles(initialCY, extAbove, extBelow, branchMinX, branchMaxX, gatewayCY, obstacles, yMargin, xMargin) {
990
+ let targetCY = initialCY;
991
+ const direction = targetCY >= gatewayCY ? 1 : -1;
992
+ const sorted = direction > 0
993
+ ? [...obstacles].sort((a, b) => a.bottom - b.bottom)
994
+ : [...obstacles].sort((a, b) => b.y - a.y);
995
+ for (const obs of sorted) {
996
+ if (branchMaxX + xMargin < obs.x || branchMinX - xMargin > obs.right)
697
997
  continue;
698
- const gatewayCY = n.bounds.y + n.bounds.height / 2;
699
- const branchId = branchStarts[0];
700
- if (!branchId)
701
- continue;
702
- const branchNode = nodeMap.get(branchId);
703
- if (!branchNode)
704
- continue;
705
- const currentCY = branchNode.bounds.y + branchNode.bounds.height / 2;
706
- const direction = currentCY < gatewayCY ? -1 : 1;
707
- let targetCY = gatewayCY + direction * GRID_CELL_HEIGHT;
708
- const chain = collectBranchChain(branchId, dag, joinId);
709
- const chainSet = new Set(chain);
710
- const minGap = GRID_CELL_HEIGHT / 2;
711
- const initialDy = targetCY - currentCY;
712
- let extraDy = 0;
713
- for (const chainNodeId of chain) {
714
- const chainNode = nodeMap.get(chainNodeId);
715
- if (!chainNode)
998
+ const newTop = targetCY - extAbove;
999
+ const newBottom = targetCY + extBelow;
1000
+ if (newBottom + yMargin <= obs.y || newTop - yMargin >= obs.bottom)
1001
+ continue;
1002
+ if (direction > 0) {
1003
+ targetCY = obs.bottom + yMargin + extAbove;
1004
+ }
1005
+ else {
1006
+ targetCY = obs.y - yMargin - extBelow;
1007
+ }
1008
+ }
1009
+ return targetCY;
1010
+ }
1011
+ /** Centre-Y of a layout node. */
1012
+ function getCY(node) {
1013
+ return node.bounds.y + node.bounds.height / 2;
1014
+ }
1015
+ /**
1016
+ * Assign integer grid-row indices to all nodes based on their final center-Y positions.
1017
+ * Nodes whose center-Y values are within EPSILON pixels are placed in the same row.
1018
+ * This eliminates pixel-tolerance guessing in port-side decisions: two nodes with the
1019
+ * same gridRow are on the same horizontal row and should connect left-to-right.
1020
+ *
1021
+ * Called once, after ALL coordinate adjustments, just before edge routing.
1022
+ */
1023
+ export function assignGridRows(layoutNodes) {
1024
+ if (layoutNodes.length === 0)
1025
+ return;
1026
+ const EPSILON = 5; // nodes within 5px center-Y share a row
1027
+ const sorted = [...layoutNodes].sort((a, b) => getCY(a) - getCY(b));
1028
+ const first = sorted[0];
1029
+ if (!first)
1030
+ return;
1031
+ let rowIdx = 0;
1032
+ let rowCY = getCY(first);
1033
+ for (const node of sorted) {
1034
+ const cy = getCY(node);
1035
+ if (cy - rowCY > EPSILON) {
1036
+ rowIdx++;
1037
+ rowCY = cy;
1038
+ }
1039
+ node.gridRow = rowIdx;
1040
+ }
1041
+ }
1042
+ /**
1043
+ * Snap nodes to common Y rows for matrix-like alignment.
1044
+ * Groups nodes that share a CY (from alignment passes), then merges
1045
+ * close groups into a single row — moving entire groups as units.
1046
+ * Boundary events are excluded (they are repositioned later).
1047
+ */
1048
+ export function snapToYRows(layoutNodes) {
1049
+ if (layoutNodes.length < 2)
1050
+ return;
1051
+ const MERGE_THRESHOLD = 35;
1052
+ const GROUP_EPSILON = 3;
1053
+ // Exclude boundary events (repositioned later in auto-layout.ts)
1054
+ const candidates = layoutNodes.filter((n) => n.type !== "boundaryEvent");
1055
+ if (candidates.length < 2)
1056
+ return;
1057
+ // Step 1: Group by current CY (nodes aligned by earlier passes share exact CY)
1058
+ const sorted = [...candidates].sort((a, b) => getCY(a) - getCY(b));
1059
+ const first = sorted[0];
1060
+ if (!first)
1061
+ return;
1062
+ const rows = [];
1063
+ let currentGroup = [first];
1064
+ let groupCY = getCY(first);
1065
+ for (let i = 1; i < sorted.length; i++) {
1066
+ const node = sorted[i];
1067
+ if (!node)
1068
+ continue;
1069
+ const cy = getCY(node);
1070
+ if (cy - groupCY <= GROUP_EPSILON) {
1071
+ currentGroup.push(node);
1072
+ }
1073
+ else {
1074
+ rows.push({ nodes: currentGroup, cy: groupCY });
1075
+ currentGroup = [node];
1076
+ groupCY = cy;
1077
+ }
1078
+ }
1079
+ rows.push({ nodes: currentGroup, cy: groupCY });
1080
+ // Step 2: Merge close consecutive rows (move smaller row to larger row's CY)
1081
+ let changed = true;
1082
+ while (changed) {
1083
+ changed = false;
1084
+ for (let i = 0; i < rows.length - 1; i++) {
1085
+ const a = rows[i];
1086
+ const b = rows[i + 1];
1087
+ if (!a || !b)
1088
+ continue;
1089
+ if (b.cy - a.cy > MERGE_THRESHOLD)
1090
+ continue;
1091
+ // Skip if merge would create same-layer overlap
1092
+ const aLayers = new Set(a.nodes.map((n) => n.layer));
1093
+ if (b.nodes.some((n) => aLayers.has(n.layer)))
1094
+ continue;
1095
+ // Move smaller group to larger group's CY
1096
+ const [target, source] = a.nodes.length >= b.nodes.length ? [a, b] : [b, a];
1097
+ for (const node of source.nodes) {
1098
+ const dy = target.cy - getCY(node);
1099
+ if (Math.abs(dy) > 0.5) {
1100
+ node.bounds.y += dy;
1101
+ if (node.labelBounds)
1102
+ node.labelBounds.y += dy;
1103
+ }
1104
+ }
1105
+ rows[i] = { nodes: [...a.nodes, ...b.nodes], cy: target.cy };
1106
+ rows.splice(i + 1, 1);
1107
+ changed = true;
1108
+ break;
1109
+ }
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Pull each branch subtree toward the baseline, closing unnecessary vertical gaps.
1114
+ * Uses per-element 2D collision detection — only actual element-to-element overlaps
1115
+ * constrain the movement, not the branch's full bounding box.
1116
+ */
1117
+ export function compactBranches(layoutNodes, dag, backEdges = []) {
1118
+ const backEdgeOriginals = buildBackEdgeOriginals(backEdges);
1119
+ const nodeMap = new Map();
1120
+ for (const n of layoutNodes)
1121
+ nodeMap.set(n.id, n);
1122
+ const baselinePath = findBaselinePath(layoutNodes, dag, backEdges);
1123
+ const baselineSet = new Set(baselinePath);
1124
+ // Collect split gateways and their branches
1125
+ const splitGateways = [];
1126
+ for (const n of layoutNodes) {
1127
+ if (!GATEWAY_TYPE_SET.has(n.type))
1128
+ continue;
1129
+ const trueSuccs = getTrueSuccessors(n.id, dag, backEdgeOriginals);
1130
+ if (trueSuccs.length < 2)
1131
+ continue;
1132
+ const joinId = findJoinGateway(n.id, dag, nodeMap);
1133
+ const branchStarts = trueSuccs.filter((s) => !baselineSet.has(s));
1134
+ if (branchStarts.length === 0)
1135
+ continue;
1136
+ splitGateways.push({ node: n, branchStarts, joinId });
1137
+ }
1138
+ // Process outermost gateways first (shallowest-first)
1139
+ splitGateways.sort((a, b) => a.node.layer - b.node.layer);
1140
+ const margin = GRID_CELL_HEIGHT / 2;
1141
+ const movedElements = new Set();
1142
+ for (const { node: gw, branchStarts, joinId } of splitGateways) {
1143
+ // Skip open-ended branches (joinId=undefined) — unbounded chains
1144
+ if (!joinId)
1145
+ continue;
1146
+ const gatewayCY = getCY(gw);
1147
+ for (const branchId of branchStarts) {
1148
+ const chain = collectBranchChain(branchId, dag, joinId);
1149
+ if (chain.some((id) => movedElements.has(id)))
1150
+ continue;
1151
+ // Get non-baseline elements in the chain
1152
+ const chainElements = [];
1153
+ for (const bid of chain) {
1154
+ if (baselineSet.has(bid))
1155
+ continue;
1156
+ const bnode = nodeMap.get(bid);
1157
+ if (bnode)
1158
+ chainElements.push(bnode);
1159
+ }
1160
+ if (chainElements.length === 0)
1161
+ continue;
1162
+ const chainSet = new Set(chain);
1163
+ const startNode = nodeMap.get(branchId);
1164
+ if (!startNode)
1165
+ continue;
1166
+ const currentCY = getCY(startNode);
1167
+ const direction = currentCY > gatewayCY ? 1 : -1;
1168
+ // Compute minimum offset from gateway (backbone + half gateway)
1169
+ const backbone = collectBranchBackbone(branchId, dag, joinId, nodeMap);
1170
+ let bbMinY = Number.POSITIVE_INFINITY;
1171
+ let bbMaxY = Number.NEGATIVE_INFINITY;
1172
+ for (const bid of backbone) {
1173
+ if (baselineSet.has(bid))
1174
+ continue;
1175
+ const bnode = nodeMap.get(bid);
1176
+ if (!bnode)
1177
+ continue;
1178
+ bbMinY = Math.min(bbMinY, bnode.bounds.y);
1179
+ bbMaxY = Math.max(bbMaxY, bnode.bounds.y + bnode.bounds.height);
1180
+ }
1181
+ const bbExtAbove = bbMinY < Number.POSITIVE_INFINITY ? currentCY - bbMinY : GRID_CELL_HEIGHT / 2;
1182
+ const bbExtBelow = bbMaxY > Number.NEGATIVE_INFINITY ? bbMaxY - currentCY : GRID_CELL_HEIGHT / 2;
1183
+ const closestCY = direction > 0 ? gatewayCY + bbExtAbove + margin : gatewayCY - bbExtBelow - margin;
1184
+ if (direction > 0 ? closestCY >= currentCY : closestCY <= currentCY)
716
1185
  continue;
717
- const chainNodeCY = chainNode.bounds.y + chainNode.bounds.height / 2;
718
- const chainNodeNewCY = chainNodeCY + initialDy;
719
- for (const peer of layoutNodes) {
720
- if (peer.layer !== chainNode.layer || chainSet.has(peer.id))
1186
+ // Per-element collision detection: for each obstacle, check every element
1187
+ // in the chain. Only actual X+Y overlaps constrain movement.
1188
+ let bestCY = closestCY;
1189
+ for (const obs of layoutNodes) {
1190
+ if (chainSet.has(obs.id))
721
1191
  continue;
722
- const peerCY = peer.bounds.y + peer.bounds.height / 2;
723
- const minDist = (chainNode.bounds.height + peer.bounds.height) / 2 + minGap;
724
- if (Math.abs(chainNodeNewCY + extraDy - peerCY) < minDist) {
725
- if (direction === -1) {
726
- const needed = peerCY - minDist - chainNodeNewCY;
727
- if (needed < extraDy)
728
- extraDy = needed;
1192
+ const obsLeft = obs.bounds.x;
1193
+ const obsRight = obs.bounds.x + obs.bounds.width;
1194
+ const obsTop = obs.labelBounds ? Math.min(obs.bounds.y, obs.labelBounds.y) : obs.bounds.y;
1195
+ const obsBottom = obs.labelBounds
1196
+ ? Math.max(obs.bounds.y + obs.bounds.height, obs.labelBounds.y + obs.labelBounds.height)
1197
+ : obs.bounds.y + obs.bounds.height;
1198
+ for (const elem of chainElements) {
1199
+ const elemRight = elem.bounds.x + elem.bounds.width;
1200
+ // X overlap check
1201
+ if (elemRight < obsLeft - margin || elem.bounds.x > obsRight + margin)
1202
+ continue;
1203
+ if (direction > 0) {
1204
+ // Element top (after moving) must be below obstacle bottom
1205
+ // elem.bounds.y + dy ≥ obsBottom + margin
1206
+ // bestCY ≥ currentCY + obsBottom + margin - elem.bounds.y
1207
+ const needed = currentCY + obsBottom + margin - elem.bounds.y;
1208
+ if (needed > bestCY)
1209
+ bestCY = needed;
729
1210
  }
730
1211
  else {
731
- const needed = peerCY + minDist - chainNodeNewCY;
732
- if (needed > extraDy)
733
- extraDy = needed;
1212
+ // Element bottom (after moving) must be above obstacle top
1213
+ const elemBottom = elem.bounds.y + elem.bounds.height;
1214
+ const needed = currentCY + obsTop - margin - elemBottom;
1215
+ if (needed < bestCY)
1216
+ bestCY = needed;
734
1217
  }
735
1218
  }
736
1219
  }
737
- }
738
- targetCY += extraDy;
739
- const dy = targetCY - currentCY;
740
- if (Math.abs(dy) > 0.5) {
1220
+ // Only move if we're actually pulling closer
1221
+ if (direction > 0 ? bestCY >= currentCY : bestCY <= currentCY)
1222
+ continue;
1223
+ const dy = bestCY - currentCY;
741
1224
  for (const bid of chain) {
1225
+ if (baselineSet.has(bid))
1226
+ continue;
742
1227
  const bnode = nodeMap.get(bid);
743
1228
  if (!bnode)
744
1229
  continue;
745
1230
  bnode.bounds.y += dy;
746
1231
  if (bnode.labelBounds)
747
1232
  bnode.labelBounds.y += dy;
1233
+ movedElements.add(bid);
748
1234
  }
749
1235
  }
750
1236
  }
@@ -769,6 +1255,46 @@ function collectBranchChain(startId, dag, joinId) {
769
1255
  }
770
1256
  return ids;
771
1257
  }
1258
+ /**
1259
+ * Collect only the backbone (spine) of a branch — following the linear path
1260
+ * and jumping over nested sub-gateways via their join.
1261
+ * Used to compute a tighter extent estimate for distribution placement.
1262
+ */
1263
+ function collectBranchBackbone(startId, dag, joinId, nodeMap) {
1264
+ const ids = [];
1265
+ let currentId = startId;
1266
+ const seen = new Set();
1267
+ while (currentId && !seen.has(currentId)) {
1268
+ if (joinId && currentId === joinId)
1269
+ break;
1270
+ seen.add(currentId);
1271
+ ids.push(currentId);
1272
+ const succs = dag.successors.get(currentId) ?? [];
1273
+ if (succs.length === 0)
1274
+ break;
1275
+ if (succs.length === 1) {
1276
+ currentId = succs[0];
1277
+ }
1278
+ else {
1279
+ // At a sub-split gateway: jump to its join
1280
+ const node = nodeMap.get(currentId);
1281
+ if (node && GATEWAY_TYPE_SET.has(node.type)) {
1282
+ const subJoinId = findJoinGateway(currentId, dag, nodeMap);
1283
+ if (subJoinId) {
1284
+ currentId = subJoinId;
1285
+ }
1286
+ else {
1287
+ // No join found — stop backbone here, fall back to full extent
1288
+ break;
1289
+ }
1290
+ }
1291
+ else {
1292
+ currentId = succs[0];
1293
+ }
1294
+ }
1295
+ }
1296
+ return ids;
1297
+ }
772
1298
  /**
773
1299
  * Resolve overlaps within each layer by pushing nodes apart.
774
1300
  * Sorts nodes by Y within each layer and ensures minimum gap.
@@ -838,7 +1364,7 @@ function swapBranchPositions(branchA, branchB, dag, nodeMap, joinId) {
838
1364
  };
839
1365
  const nodesA = collectBranch(branchA);
840
1366
  const nodesB = collectBranch(branchB);
841
- // Swap y-positions pairwise; if one branch is shorter, remaining nodes keep position
1367
+ // Swap center-Y pairwise (not raw Y) so elements of different heights stay aligned
842
1368
  const swapCount = Math.min(nodesA.length, nodesB.length);
843
1369
  for (let i = 0; i < swapCount; i++) {
844
1370
  const idA = nodesA[i];
@@ -849,20 +1375,18 @@ function swapBranchPositions(branchA, branchB, dag, nodeMap, joinId) {
849
1375
  const b = nodeMap.get(idB);
850
1376
  if (!a || !b)
851
1377
  continue;
852
- const tmpY = a.bounds.y;
853
- a.bounds.y = b.bounds.y;
854
- b.bounds.y = tmpY;
855
- if (a.labelBounds && b.labelBounds) {
856
- const tmpLabelY = a.labelBounds.y;
857
- a.labelBounds.y = b.labelBounds.y;
858
- b.labelBounds.y = tmpLabelY;
859
- }
860
- else if (a.labelBounds) {
861
- a.labelBounds.y += b.bounds.y - a.bounds.y;
1378
+ const aCY = a.bounds.y + a.bounds.height / 2;
1379
+ const bCY = b.bounds.y + b.bounds.height / 2;
1380
+ const aNewY = bCY - a.bounds.height / 2;
1381
+ const bNewY = aCY - b.bounds.height / 2;
1382
+ if (a.labelBounds) {
1383
+ a.labelBounds.y += aNewY - a.bounds.y;
862
1384
  }
863
- else if (b.labelBounds) {
864
- b.labelBounds.y += a.bounds.y - b.bounds.y;
1385
+ if (b.labelBounds) {
1386
+ b.labelBounds.y += bNewY - b.bounds.y;
865
1387
  }
1388
+ a.bounds.y = aNewY;
1389
+ b.bounds.y = bNewY;
866
1390
  }
867
1391
  }
868
1392
  //# sourceMappingURL=coordinates.js.map