@crazyhappyone/auto-graph 0.2.7 → 0.2.9

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.d.cts CHANGED
@@ -436,7 +436,8 @@ interface ConstraintSolverInput {
436
436
  minSiblingGap?: number;
437
437
  distributeContainedChildren?: boolean | "spread";
438
438
  /** When "spread" or true, distribute children inside non-contract
439
- * swimlane lane content boxes (Issue #60). Default "spread". */
439
+ * swimlane lane content boxes (Issue #60). Opt-in: no redistribution
440
+ * occurs unless explicitly set. */
440
441
  distributeSwimlaneChildren?: boolean | "spread";
441
442
  boxes: ReadonlyMap<string, Box>;
442
443
  /** Swimlanes for lane-aware child distribution (Issue #60). */
@@ -806,7 +807,7 @@ interface SolveDiagramOptions {
806
807
  minSiblingGap?: number;
807
808
  distributeContainedChildren?: boolean | "spread";
808
809
  /** When "spread", distribute children within non-contract swimlane
809
- * lanes (Issue #60). Default "spread". */
810
+ * lanes (Issue #60). Opt-in: no redistribution occurs unless explicitly set. */
810
811
  distributeSwimlaneChildren?: boolean | "spread";
811
812
  pageBounds?: {
812
813
  width: number;
package/dist/index.d.ts CHANGED
@@ -436,7 +436,8 @@ interface ConstraintSolverInput {
436
436
  minSiblingGap?: number;
437
437
  distributeContainedChildren?: boolean | "spread";
438
438
  /** When "spread" or true, distribute children inside non-contract
439
- * swimlane lane content boxes (Issue #60). Default "spread". */
439
+ * swimlane lane content boxes (Issue #60). Opt-in: no redistribution
440
+ * occurs unless explicitly set. */
440
441
  distributeSwimlaneChildren?: boolean | "spread";
441
442
  boxes: ReadonlyMap<string, Box>;
442
443
  /** Swimlanes for lane-aware child distribution (Issue #60). */
@@ -806,7 +807,7 @@ interface SolveDiagramOptions {
806
807
  minSiblingGap?: number;
807
808
  distributeContainedChildren?: boolean | "spread";
808
809
  /** When "spread", distribute children within non-contract swimlane
809
- * lanes (Issue #60). Default "spread". */
810
+ * lanes (Issue #60). Opt-in: no redistribution occurs unless explicitly set. */
810
811
  distributeSwimlaneChildren?: boolean | "spread";
811
812
  pageBounds?: {
812
813
  width: number;
package/dist/index.js CHANGED
@@ -176,6 +176,28 @@ function applyLayoutConstraints(input) {
176
176
  if (input.distributeContainedChildren) {
177
177
  yieldFixedPositionLocks(input, boxes, locks);
178
178
  }
179
+ if (input.swimlanes !== void 0 && input.swimlanes.length > 0 && input.distributeSwimlaneChildren) {
180
+ for (const swimlane of input.swimlanes) {
181
+ if (swimlane.layout === "contract") continue;
182
+ for (const lane of swimlane.lanes) {
183
+ const fixedChildren = [];
184
+ let participantCount = 0;
185
+ for (const childId of lane.children) {
186
+ const lock = locks.get(childId);
187
+ if (lock === void 0) {
188
+ participantCount += 1;
189
+ } else if (lock.source === "fixed-position") {
190
+ participantCount += 1;
191
+ fixedChildren.push(childId);
192
+ }
193
+ }
194
+ if (participantCount < 2) continue;
195
+ for (const childId of fixedChildren) {
196
+ locks.delete(childId);
197
+ }
198
+ }
199
+ }
200
+ }
179
201
  applyContainment(input.constraints, boxes, locks, diagnostics, false);
180
202
  applyRelative(input.constraints, boxes, locks, diagnostics);
181
203
  applyAlign(input.constraints, boxes, locks, diagnostics);
@@ -195,6 +217,9 @@ function applyLayoutConstraints(input) {
195
217
  applyDistributeContained(input, boxes, locks, diagnostics);
196
218
  dedupReplayDiagnostics(diagnostics, diagBefore);
197
219
  }
220
+ if (input.swimlanes !== void 0 && input.swimlanes.length > 0 && input.distributeSwimlaneChildren) {
221
+ distributeSwimlaneChildren(input, boxes, locks, diagnostics);
222
+ }
198
223
  removeResolvedConstraintDiagnostics(input.constraints, boxes, diagnostics);
199
224
  reportOverlaps(
200
225
  boxes,
@@ -1034,9 +1059,6 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
1034
1059
  }
1035
1060
  });
1036
1061
  }
1037
- if (input.swimlanes !== void 0 && input.swimlanes.length > 0 && input.distributeSwimlaneChildren) {
1038
- distributeSwimlaneChildren(input, boxes, locks, diagnostics);
1039
- }
1040
1062
  }
1041
1063
  function distributeSwimlaneChildren(input, boxes, locks, diagnostics) {
1042
1064
  const spread = input.distributeSwimlaneChildren === "spread";
@@ -1076,6 +1098,7 @@ function distributeSwimlaneChildren(input, boxes, locks, diagnostics) {
1076
1098
  effectiveGap = minGap + remaining / (unlocked.length - 1);
1077
1099
  }
1078
1100
  unlocked.sort((a, b) => a.box[axis] - b.box[axis]);
1101
+ reserved.sort((a, b) => a.start - b.start);
1079
1102
  let pos = contentStart;
1080
1103
  for (const child of unlocked) {
1081
1104
  pos = advancePastReserved(pos, child.box[mainSize], reserved, minGap);
@@ -4726,15 +4749,12 @@ var BinaryHeap = class {
4726
4749
  let smallestIdx = idx;
4727
4750
  const leftIdx = (idx << 1) + 1;
4728
4751
  const rightIdx = leftIdx + 1;
4729
- if (leftIdx < size && this._less(
4730
- this._data[leftIdx],
4731
- this._data[smallestIdx]
4732
- )) {
4752
+ if (leftIdx < size && this._less(this._data[leftIdx], entry)) {
4733
4753
  smallestIdx = leftIdx;
4734
4754
  }
4735
4755
  if (rightIdx < size && this._less(
4736
4756
  this._data[rightIdx],
4737
- this._data[smallestIdx]
4757
+ smallestIdx === leftIdx ? this._data[leftIdx] : entry
4738
4758
  )) {
4739
4759
  smallestIdx = rightIdx;
4740
4760
  }
@@ -4801,8 +4821,59 @@ function findObstacleFreePath(source, target, obstacles, options = {}, diagnosti
4801
4821
  turnPenalty,
4802
4822
  segmentPenalty
4803
4823
  );
4804
- if (path === null) return null;
4805
- return simplifyRoute(path);
4824
+ if (path !== null) {
4825
+ const simplified = simplifyRoute(path);
4826
+ const filteredSet = new Set(filtered);
4827
+ if (useCorridor && obstacles.some((o) => !filteredSet.has(o))) {
4828
+ let crossesExcluded = false;
4829
+ for (let i = 0; i < simplified.length - 1; i++) {
4830
+ const a = simplified[i];
4831
+ const b = simplified[i + 1];
4832
+ for (const obs of obstacles) {
4833
+ if (filteredSet.has(obs)) continue;
4834
+ if (segmentCrossesBoxStrict(a, b, obs, margin)) {
4835
+ crossesExcluded = true;
4836
+ break;
4837
+ }
4838
+ }
4839
+ if (crossesExcluded) break;
4840
+ }
4841
+ if (!crossesExcluded) return simplified;
4842
+ } else {
4843
+ return simplified;
4844
+ }
4845
+ }
4846
+ if (!useCorridor) return null;
4847
+ const xsFull = collectXs(source, target, obstacles, margin);
4848
+ const ysFull = collectYs(source, target, obstacles, margin);
4849
+ if (xsFull.length * ysFull.length > maxNodes) {
4850
+ diagnostics?.push({
4851
+ severity: "warning",
4852
+ code: "routing.astar.grid_overflow",
4853
+ message: `A* full-retry grid overflow: ${xsFull.length * ysFull.length} nodes > ${maxNodes} limit. Falling back to heuristic routing.`,
4854
+ detail: { xsCount: xsFull.length, ysCount: ysFull.length, maxNodes }
4855
+ });
4856
+ return null;
4857
+ }
4858
+ const { nodes: nodesFull, nodeIndex: idxFull } = buildGraph(xsFull, ysFull);
4859
+ connectHorizontalEdges(
4860
+ nodesFull,
4861
+ ysFull,
4862
+ obstacles,
4863
+ endpointObstacles,
4864
+ margin
4865
+ );
4866
+ connectVerticalEdges(nodesFull, xsFull, obstacles, endpointObstacles, margin);
4867
+ const pathFull = aStarSearch(
4868
+ nodesFull,
4869
+ idxFull,
4870
+ source,
4871
+ target,
4872
+ turnPenalty,
4873
+ segmentPenalty
4874
+ );
4875
+ if (pathFull === null) return null;
4876
+ return simplifyRoute(pathFull);
4806
4877
  }
4807
4878
  function filterObstaclesByCorridor(source, target, obstacles, endpointObstacles, margin) {
4808
4879
  const cx1 = Math.min(source.x, target.x) - margin;
@@ -5029,7 +5100,7 @@ function findCornerGraphPath(source, target, obstacles, options = {}, diagnostic
5029
5100
  const turnPenalty = options.turnPenalty ?? 50;
5030
5101
  const segmentPenalty = options.segmentPenalty ?? 1;
5031
5102
  const endpointObstacles = options.endpointObstacles ?? [];
5032
- const maxCorners = options.maxCorners ?? 300;
5103
+ const maxCorners = options.maxCorners ?? 600;
5033
5104
  const vertices = collectCornerVertices(source, target, obstacles, margin);
5034
5105
  if (vertices.length > maxCorners) {
5035
5106
  diagnostics?.push({
@@ -5398,17 +5469,36 @@ function routeEdge(input) {
5398
5469
  input.source.center,
5399
5470
  targetAnchor
5400
5471
  );
5401
- const cornerPath = findCornerGraphPath(
5472
+ const allObstacles = [...softObstacles, ...hardObstacles];
5473
+ const corridorObstacles = filterObstaclesByCorridor(
5402
5474
  source,
5403
5475
  target,
5404
- [...softObstacles, ...hardObstacles],
5476
+ allObstacles,
5477
+ [],
5478
+ // endpointObstacles passed separately via options
5479
+ 32
5480
+ );
5481
+ const cornerObstacles = corridorObstacles.length === 0 && allObstacles.length > 0 ? allObstacles : corridorObstacles;
5482
+ let cornerPath = findCornerGraphPath(
5483
+ source,
5484
+ target,
5485
+ cornerObstacles,
5405
5486
  { endpointObstacles, margin: 2 },
5406
5487
  diagnostics
5407
5488
  );
5489
+ if (cornerPath === null && cornerObstacles.length < allObstacles.length) {
5490
+ cornerPath = findCornerGraphPath(
5491
+ source,
5492
+ target,
5493
+ allObstacles,
5494
+ { endpointObstacles, margin: 2 },
5495
+ diagnostics
5496
+ );
5497
+ }
5408
5498
  const path = cornerPath ?? findObstacleFreePath(
5409
5499
  source,
5410
5500
  target,
5411
- [...softObstacles, ...hardObstacles],
5501
+ allObstacles,
5412
5502
  { endpointObstacles, margin: 0 },
5413
5503
  diagnostics
5414
5504
  );
@@ -5429,6 +5519,66 @@ function routeEdge(input) {
5429
5519
  checkBacktracking(finalized, source, target, diagnostics);
5430
5520
  return { points: finalized, diagnostics };
5431
5521
  }
5522
+ if (cornerPath !== null) {
5523
+ const fullCornerPath = cornerObstacles.length < allObstacles.length ? findCornerGraphPath(
5524
+ source,
5525
+ target,
5526
+ allObstacles,
5527
+ { endpointObstacles, margin: 2 },
5528
+ diagnostics
5529
+ ) : null;
5530
+ if (fullCornerPath !== null && fullCornerPath.length >= 2) {
5531
+ const fullFinalized = finalizeRoute(
5532
+ fullCornerPath,
5533
+ softObstacles,
5534
+ hardObstacles,
5535
+ diagnostics,
5536
+ softObstacleIndex,
5537
+ hardObstacleIndex
5538
+ );
5539
+ if (!routeIntersectsObstacles(
5540
+ fullFinalized,
5541
+ softObstacles,
5542
+ softObstacleIndex
5543
+ ) && !routeIntersectsObstacles(
5544
+ fullFinalized,
5545
+ hardObstacles,
5546
+ hardObstacleIndex
5547
+ )) {
5548
+ checkBacktracking(fullFinalized, source, target, diagnostics);
5549
+ return { points: fullFinalized, diagnostics };
5550
+ }
5551
+ }
5552
+ const gridPath = findObstacleFreePath(
5553
+ source,
5554
+ target,
5555
+ allObstacles,
5556
+ { endpointObstacles, margin: 0 },
5557
+ diagnostics
5558
+ );
5559
+ if (gridPath !== null && gridPath.length >= 2) {
5560
+ const gridFinalized = finalizeRoute(
5561
+ gridPath,
5562
+ softObstacles,
5563
+ hardObstacles,
5564
+ diagnostics,
5565
+ softObstacleIndex,
5566
+ hardObstacleIndex
5567
+ );
5568
+ if (!routeIntersectsObstacles(
5569
+ gridFinalized,
5570
+ softObstacles,
5571
+ softObstacleIndex
5572
+ ) && !routeIntersectsObstacles(
5573
+ gridFinalized,
5574
+ hardObstacles,
5575
+ hardObstacleIndex
5576
+ )) {
5577
+ checkBacktracking(gridFinalized, source, target, diagnostics);
5578
+ return { points: gridFinalized, diagnostics };
5579
+ }
5580
+ }
5581
+ }
5432
5582
  }
5433
5583
  }
5434
5584
  }
@@ -6529,7 +6679,7 @@ function solveDiagram(diagram, options = {}) {
6529
6679
  edges: styledEdges
6530
6680
  });
6531
6681
  diagnostics.push(...layout2.diagnostics);
6532
- const initialNodeBoxes = initialLayoutMode === "positions" ? layout2.boxes : wrapVerticalStackIfNeeded(
6682
+ const initialNodeBoxes = initialLayoutMode === "positions" || diagram.direction !== "LR" && diagram.direction !== "RL" ? layout2.boxes : wrapVerticalStackIfNeeded(
6533
6683
  layout2.boxes,
6534
6684
  styledNodes,
6535
6685
  styledEdges,
@@ -6537,7 +6687,8 @@ function solveDiagram(diagram, options = {}) {
6537
6687
  options,
6538
6688
  diagnostics
6539
6689
  );
6540
- if ((diagram.direction === "TB" || diagram.direction === "BT") && options.maxRowDepth !== void 0) {
6690
+ if ((diagram.direction === "TB" || diagram.direction === "BT") && (options.maxRowDepth !== void 0 || options.targetAspectRatio !== void 0)) {
6691
+ const diagCountBefore = diagnostics.length;
6541
6692
  const rewrapped = wrapHorizontalStackIfNeeded(
6542
6693
  initialNodeBoxes,
6543
6694
  styledNodes,
@@ -6548,6 +6699,20 @@ function solveDiagram(diagram, options = {}) {
6548
6699
  for (const [id, box] of rewrapped) {
6549
6700
  initialNodeBoxes.set(id, box);
6550
6701
  }
6702
+ if (diagnostics.length > diagCountBefore) {
6703
+ for (const node of styledNodes) {
6704
+ if (node.position !== void 0 && rewrapped.has(node.id)) {
6705
+ const rwBox = rewrapped.get(node.id);
6706
+ const idx = styledNodes.indexOf(node);
6707
+ if (idx !== -1) {
6708
+ styledNodes[idx] = {
6709
+ ...node,
6710
+ position: { x: rwBox.x, y: rwBox.y }
6711
+ };
6712
+ }
6713
+ }
6714
+ }
6715
+ }
6551
6716
  }
6552
6717
  if (useRecursive && "groupBoxes" in layout2) {
6553
6718
  const recursiveLayout = layout2;
@@ -6561,7 +6726,7 @@ function solveDiagram(diagram, options = {}) {
6561
6726
  overlapSpacing: options?.overlapSpacing ?? 40,
6562
6727
  ...options.minSiblingGap === void 0 ? {} : { minSiblingGap: options.minSiblingGap },
6563
6728
  distributeContainedChildren: options.distributeContainedChildren ?? true,
6564
- distributeSwimlaneChildren: options.distributeSwimlaneChildren ?? "spread",
6729
+ ...options.distributeSwimlaneChildren !== void 0 ? { distributeSwimlaneChildren: options.distributeSwimlaneChildren } : {},
6565
6730
  swimlanes: styledSwimlanes,
6566
6731
  boxes: initialNodeBoxes,
6567
6732
  nodes: styledNodes,
@@ -7280,9 +7445,14 @@ function wrapHorizontalStackIfNeeded(boxes, nodes, direction, options, diagnosti
7280
7445
  if (!isStackRunaway(boxes, nodes, direction, options)) {
7281
7446
  return new Map(boxes);
7282
7447
  }
7283
- const maxRowDepth = options.maxRowDepth;
7284
- if (maxRowDepth === void 0 || nodes.length <= maxRowDepth) {
7285
- return new Map(boxes);
7448
+ let maxRowDepth = options.maxRowDepth;
7449
+ if (maxRowDepth === void 0 || maxRowDepth <= 0 || nodes.length <= maxRowDepth) {
7450
+ if (maxRowDepth === void 0 && options.targetAspectRatio !== void 0) {
7451
+ maxRowDepth = Math.ceil(Math.sqrt(nodes.length));
7452
+ if (nodes.length <= maxRowDepth) return new Map(boxes);
7453
+ } else {
7454
+ return new Map(boxes);
7455
+ }
7286
7456
  }
7287
7457
  const ordered = [...nodes].sort((a, b) => {
7288
7458
  const ba = boxes.get(a.id);
@@ -7343,10 +7513,10 @@ function reportVerticalRunaway(boxes, nodes, edges, direction, options, diagnost
7343
7513
  });
7344
7514
  }
7345
7515
  function isStackRunaway(boxes, nodes, direction, options) {
7346
- if (options.maxStackDepth === void 0 && options.preferredAspectRatio === void 0) {
7516
+ if (options.maxStackDepth === void 0 && options.preferredAspectRatio === void 0 && options.targetAspectRatio === void 0 && options.maxRowDepth === void 0) {
7347
7517
  return false;
7348
7518
  }
7349
- if (nodes.length < 2 || direction !== "LR" && direction !== "RL") {
7519
+ if (nodes.length < 2) {
7350
7520
  return false;
7351
7521
  }
7352
7522
  const nodeBoxes = nodes.map((node) => boxes.get(node.id)).filter((box) => box !== void 0);
@@ -7354,11 +7524,18 @@ function isStackRunaway(boxes, nodes, direction, options) {
7354
7524
  return false;
7355
7525
  }
7356
7526
  const bounds = unionBoxes(nodeBoxes);
7357
- const aspectRatio = bounds.width <= 0 ? Number.POSITIVE_INFINITY : bounds.height / bounds.width;
7358
- const preferred = options.preferredAspectRatio ?? 3;
7359
- if (aspectRatio < preferred) {
7527
+ const isHorizontal = direction === "TB" || direction === "BT";
7528
+ const aspectRatio = isHorizontal ? bounds.height <= 0 ? Number.POSITIVE_INFINITY : bounds.width / bounds.height : bounds.width <= 0 ? Number.POSITIVE_INFINITY : bounds.height / bounds.width;
7529
+ const preferred = isHorizontal ? options.targetAspectRatio ?? options.preferredAspectRatio ?? 3 : options.preferredAspectRatio ?? 3;
7530
+ if ((options.preferredAspectRatio !== void 0 || options.targetAspectRatio !== void 0) && aspectRatio < preferred) {
7360
7531
  return false;
7361
7532
  }
7533
+ if (isHorizontal) {
7534
+ const yCenters = nodeBoxes.map((box) => box.y + box.height / 2);
7535
+ const ySpread = Math.max(...yCenters) - Math.min(...yCenters);
7536
+ const maxHeight = Math.max(...nodeBoxes.map((box) => box.height));
7537
+ return ySpread <= Math.max(maxHeight, options.overlapSpacing ?? 40);
7538
+ }
7362
7539
  const xCenters = nodeBoxes.map((box) => box.x + box.width / 2);
7363
7540
  const xSpread = Math.max(...xCenters) - Math.min(...xCenters);
7364
7541
  const maxWidth = Math.max(...nodeBoxes.map((box) => box.width));
@@ -7422,7 +7599,17 @@ function applyVerticalSwimlaneContract(swimlane, edges, topToBottomFlow, nodeBox
7422
7599
  );
7423
7600
  const rankSpacing = Math.max(96, maxRankStackHeight + padding);
7424
7601
  const contentHeight = maxRank === 0 ? maxChildHeight : maxRankStackHeight + maxRank * rankSpacing;
7425
- const slotWidth = Math.max(...populatedBounds.map((box) => box.width)) + padding * 2;
7602
+ const spreadWidth = maxCrossAxisSpreadWidth(
7603
+ swimlane,
7604
+ nodeBoxes,
7605
+ flowRanks,
7606
+ locks,
7607
+ rankStackGap
7608
+ );
7609
+ const slotWidth = Math.max(
7610
+ Math.max(...populatedBounds.map((box) => box.width)),
7611
+ spreadWidth
7612
+ ) + padding * 2;
7426
7613
  const laneStep = slotWidth + laneGutter;
7427
7614
  const laneContentTop = top + headerHeight + padding;
7428
7615
  for (let index = 0; index < swimlane.lanes.length; index += 1) {
@@ -7436,6 +7623,24 @@ function applyVerticalSwimlaneContract(swimlane, edges, topToBottomFlow, nodeBox
7436
7623
  y: laneContentTop
7437
7624
  };
7438
7625
  if (maxRank === 0) {
7626
+ const distributable = lane.children.filter(
7627
+ (childId) => !locks.has(childId)
7628
+ );
7629
+ if (distributable.length >= CROSS_AXIS_SPREAD_THRESHOLD) {
7630
+ moveRankedVerticalLaneChildren(
7631
+ lane.children,
7632
+ nodeBoxes,
7633
+ locks,
7634
+ diagnostics,
7635
+ movedChildIds,
7636
+ flowRanks,
7637
+ rankSpacing,
7638
+ rankStackGap,
7639
+ { x: target.x, y: laneContentTop },
7640
+ slotWidth - padding * 2
7641
+ );
7642
+ continue;
7643
+ }
7439
7644
  moveLaneChildren(
7440
7645
  lane.children,
7441
7646
  nodeBoxes,
@@ -7458,10 +7663,8 @@ function applyVerticalSwimlaneContract(swimlane, edges, topToBottomFlow, nodeBox
7458
7663
  flowRanks,
7459
7664
  rankSpacing,
7460
7665
  rankStackGap,
7461
- {
7462
- x: target.x - bounds.x,
7463
- y: laneContentTop
7464
- }
7666
+ { x: target.x, y: laneContentTop },
7667
+ slotWidth - padding * 2
7465
7668
  );
7466
7669
  }
7467
7670
  return {
@@ -7570,31 +7773,99 @@ function maxVerticalRankStackHeight(swimlane, nodeBoxes, flowRanks, gap) {
7570
7773
  }
7571
7774
  return maxHeight;
7572
7775
  }
7573
- function moveRankedVerticalLaneChildren(childIds, nodeBoxes, locks, diagnostics, movedChildIds, flowRanks, rankSpacing, rankStackGap, target) {
7776
+ var CROSS_AXIS_SPREAD_THRESHOLD = 3;
7777
+ function crossAxisSpreadWidth(items, gap) {
7778
+ return items.reduce(
7779
+ (sum, item, index) => sum + item.box.width + (index === 0 ? 0 : gap),
7780
+ 0
7781
+ );
7782
+ }
7783
+ function maxCrossAxisSpreadWidth(swimlane, nodeBoxes, flowRanks, locks, gap) {
7784
+ let maxWidth = 0;
7785
+ for (const lane of swimlane.lanes) {
7786
+ for (const stack of rankStacks(
7787
+ lane.children,
7788
+ nodeBoxes,
7789
+ flowRanks
7790
+ ).values()) {
7791
+ const unlocked = stack.filter((item) => !locks.has(item.childId));
7792
+ if (unlocked.length < CROSS_AXIS_SPREAD_THRESHOLD) continue;
7793
+ maxWidth = Math.max(maxWidth, crossAxisSpreadWidth(unlocked, gap));
7794
+ }
7795
+ }
7796
+ return maxWidth;
7797
+ }
7798
+ function moveRankedVerticalLaneChildren(childIds, nodeBoxes, locks, diagnostics, movedChildIds, flowRanks, rankSpacing, rankStackGap, target, contentWidth) {
7574
7799
  for (const [rank, stack] of rankStacks(childIds, nodeBoxes, flowRanks)) {
7575
- let yOffset = 0;
7800
+ const unlocked = [];
7576
7801
  for (const item of stack) {
7577
- const { childId, box } = item;
7578
- if (locks.has(childId)) {
7802
+ if (locks.has(item.childId)) {
7579
7803
  diagnostics.push({
7580
7804
  severity: "warning",
7581
7805
  code: "constraints.locked-target-not-moved",
7582
- message: `Locked child ${childId} was not moved into contract swimlane slot.`,
7806
+ message: `Locked child ${item.childId} was not moved into contract swimlane slot.`,
7583
7807
  path: ["swimlanes"],
7584
- detail: { nodeId: childId }
7808
+ detail: { nodeId: item.childId }
7585
7809
  });
7586
- continue;
7810
+ } else {
7811
+ unlocked.push(item);
7587
7812
  }
7813
+ }
7814
+ if (unlocked.length === 0) continue;
7815
+ if (unlocked.length === 1) {
7816
+ const { childId, box } = unlocked[0];
7588
7817
  const next = {
7589
7818
  ...box,
7590
- x: box.x + target.x,
7591
- y: target.y + rank * rankSpacing + yOffset
7819
+ x: target.x + (contentWidth - box.width) / 2,
7820
+ y: target.y + rank * rankSpacing
7592
7821
  };
7593
7822
  if (next.x !== box.x || next.y !== box.y) {
7594
7823
  movedChildIds.add(childId);
7595
7824
  }
7596
7825
  nodeBoxes.set(childId, next);
7597
- yOffset += box.height + rankStackGap;
7826
+ } else {
7827
+ const shouldSpread = unlocked.length >= CROSS_AXIS_SPREAD_THRESHOLD;
7828
+ if (!shouldSpread) {
7829
+ let yOffset = 0;
7830
+ for (const { childId, box } of unlocked) {
7831
+ const next = {
7832
+ ...box,
7833
+ x: target.x + (contentWidth - box.width) / 2,
7834
+ y: target.y + rank * rankSpacing + yOffset
7835
+ };
7836
+ if (next.x !== box.x || next.y !== box.y) {
7837
+ movedChildIds.add(childId);
7838
+ }
7839
+ nodeBoxes.set(childId, next);
7840
+ yOffset += box.height + rankStackGap;
7841
+ }
7842
+ } else {
7843
+ const packedWidth = crossAxisSpreadWidth(unlocked, rankStackGap);
7844
+ let xCursor = target.x + Math.max(0, (contentWidth - packedWidth) / 2);
7845
+ for (const { childId, box } of unlocked) {
7846
+ const next = {
7847
+ ...box,
7848
+ x: xCursor,
7849
+ y: target.y + rank * rankSpacing
7850
+ };
7851
+ if (next.x !== box.x || next.y !== box.y) {
7852
+ movedChildIds.add(childId);
7853
+ }
7854
+ nodeBoxes.set(childId, next);
7855
+ xCursor += box.width + rankStackGap;
7856
+ }
7857
+ diagnostics.push({
7858
+ severity: "info",
7859
+ code: "swimlane_contract.cross_axis_distributed",
7860
+ message: `Spread ${unlocked.length} same-rank children horizontally in contract lane (rank ${rank}).`,
7861
+ path: ["swimlanes"],
7862
+ detail: {
7863
+ rank,
7864
+ childCount: unlocked.length,
7865
+ contentWidth
7866
+ }
7867
+ });
7868
+ }
7598
7869
  }
7599
7870
  }
7600
7871
  }
@@ -7933,7 +8204,7 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
7933
8204
  });
7934
8205
  continue;
7935
8206
  }
7936
- const ports = node.ports === void 0 ? void 0 : coordinatePorts(node, box, options.portShifting, diagnostics);
8207
+ const ports = node.ports === void 0 ? void 0 : coordinatePorts(node, box, options.portShifting);
7937
8208
  const geometry = computeShapeGeometry({
7938
8209
  shape: node.shape,
7939
8210
  box,
@@ -8027,7 +8298,7 @@ function expandNodeBoxesForPorts(nodes, boxes, options, diagnostics) {
8027
8298
  }
8028
8299
  }
8029
8300
  }
8030
- function coordinatePorts(node, nodeBox, portShifting, diagnostics) {
8301
+ function coordinatePorts(node, nodeBox, portShifting) {
8031
8302
  const portsBySide = /* @__PURE__ */ new Map();
8032
8303
  for (const port of node.ports ?? []) {
8033
8304
  const ports = portsBySide.get(port.side) ?? [];
@@ -8050,9 +8321,7 @@ function coordinatePorts(node, nodeBox, portShifting, diagnostics) {
8050
8321
  side,
8051
8322
  index,
8052
8323
  sorted.length,
8053
- portShifting,
8054
- diagnostics,
8055
- node.id
8324
+ portShifting
8056
8325
  );
8057
8326
  const box = portBox(anchor);
8058
8327
  coordinated.push({ ...port, box, anchor });
@@ -8060,32 +8329,16 @@ function coordinatePorts(node, nodeBox, portShifting, diagnostics) {
8060
8329
  }
8061
8330
  return coordinated.sort((a, b) => a.id.localeCompare(b.id));
8062
8331
  }
8063
- function portAnchor(nodeBox, side, index, count, portShifting, diagnostics, nodeId) {
8332
+ function portAnchor(nodeBox, side, index, count, portShifting) {
8064
8333
  const shiftingEnabled = portShifting?.enabled ?? true;
8065
8334
  const requestedSpacing = portShifting?.spacing ?? 24;
8066
8335
  const maxOffset = side === "left" || side === "right" ? nodeBox.height / 2 : nodeBox.width / 2;
8067
8336
  const availableSpan = 2 * maxOffset;
8068
8337
  const minSpacing = PORT_BOX_SIZE + MIN_PORT_EDGE_GAP;
8069
- const effectiveSpacing = shiftingEnabled && count > 1 ? Math.max(
8338
+ const spacing = shiftingEnabled && count > 1 ? Math.max(
8070
8339
  Math.min(requestedSpacing, availableSpan / (count - 1)),
8071
8340
  minSpacing
8072
8341
  ) : requestedSpacing;
8073
- if (shiftingEnabled && count > 1 && effectiveSpacing < requestedSpacing && diagnostics !== void 0 && nodeId !== void 0) {
8074
- diagnostics.push({
8075
- severity: "warning",
8076
- code: "port_constraint_overlap",
8077
- message: `Port spacing on ${nodeId} ${side} compressed from ${requestedSpacing}px to ${Math.round(effectiveSpacing)}px for ${count} ports.`,
8078
- path: ["nodes", nodeId, "ports"],
8079
- detail: {
8080
- nodeId,
8081
- side,
8082
- requestedSpacing,
8083
- effectiveSpacing: Math.round(effectiveSpacing),
8084
- portCount: count
8085
- }
8086
- });
8087
- }
8088
- const spacing = effectiveSpacing;
8089
8342
  const centeredOffset = shiftingEnabled ? (index - (count - 1) / 2) * spacing : 0;
8090
8343
  switch (side) {
8091
8344
  case "left":