@crazyhappyone/auto-graph 0.2.5 → 0.2.7

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/cli/index.js CHANGED
@@ -2249,6 +2249,69 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
2249
2249
  }
2250
2250
  });
2251
2251
  }
2252
+ if (input.swimlanes !== void 0 && input.swimlanes.length > 0 && input.distributeSwimlaneChildren) {
2253
+ distributeSwimlaneChildren(input, boxes, locks, diagnostics);
2254
+ }
2255
+ }
2256
+ function distributeSwimlaneChildren(input, boxes, locks, diagnostics) {
2257
+ const spread = input.distributeSwimlaneChildren === "spread";
2258
+ const minGap = input.minSiblingGap ?? 8;
2259
+ for (const swimlane of input.swimlanes) {
2260
+ if (swimlane.layout === "contract") continue;
2261
+ const isVertical = swimlane.orientation !== "horizontal";
2262
+ const axis = isVertical ? "x" : "y";
2263
+ const mainSize = isVertical ? "width" : "height";
2264
+ for (const lane of swimlane.lanes) {
2265
+ if (lane.children.length < 2) continue;
2266
+ const unlocked = [];
2267
+ const reserved = [];
2268
+ for (const childId of lane.children) {
2269
+ const box = boxes.get(childId);
2270
+ if (box === void 0) continue;
2271
+ if (locks.has(childId)) {
2272
+ const lock = locks.get(childId);
2273
+ if (lock.source === "fixed-position") {
2274
+ unlocked.push({ id: childId, box });
2275
+ continue;
2276
+ }
2277
+ reserved.push(intervalForBox(box, axis, mainSize));
2278
+ continue;
2279
+ }
2280
+ unlocked.push({ id: childId, box });
2281
+ }
2282
+ if (unlocked.length < 2) continue;
2283
+ const contentStart = isVertical ? Math.min(...unlocked.map((c) => c.box.x)) : Math.min(...unlocked.map((c) => c.box.y));
2284
+ const contentEnd = isVertical ? Math.max(...unlocked.map((c) => c.box.x + c.box.width)) : Math.max(...unlocked.map((c) => c.box.y + c.box.height));
2285
+ const contentSpan = contentEnd - contentStart;
2286
+ const totalChildSpan = unlocked.reduce((s, c) => s + c.box[mainSize], 0);
2287
+ const reservedSpan = reserved.reduce((s, r) => s + (r.end - r.start), 0);
2288
+ let effectiveGap = minGap;
2289
+ const remaining = contentSpan - totalChildSpan - reservedSpan - minGap * (unlocked.length - 1);
2290
+ if (spread && remaining > 0) {
2291
+ effectiveGap = minGap + remaining / (unlocked.length - 1);
2292
+ }
2293
+ unlocked.sort((a, b) => a.box[axis] - b.box[axis]);
2294
+ let pos = contentStart;
2295
+ for (const child of unlocked) {
2296
+ pos = advancePastReserved(pos, child.box[mainSize], reserved, minGap);
2297
+ const newBox = { ...child.box };
2298
+ if (axis === "x") {
2299
+ newBox.x = pos;
2300
+ } else {
2301
+ newBox.y = pos;
2302
+ }
2303
+ boxes.set(child.id, newBox);
2304
+ locks.delete(child.id);
2305
+ pos += child.box[mainSize] + effectiveGap;
2306
+ }
2307
+ diagnostics.push({
2308
+ severity: "info",
2309
+ code: "intra_container_distributed",
2310
+ message: `Distributed ${unlocked.length} children in swimlane ${lane.id} along ${axis}.`,
2311
+ detail: { containerId: lane.id, count: unlocked.length, axis }
2312
+ });
2313
+ }
2314
+ }
2252
2315
  }
2253
2316
  function intervalForBox(box, axis, mainSize) {
2254
2317
  return { start: box[axis], end: box[axis] + box[mainSize] };
@@ -3661,6 +3724,397 @@ function connectedComponents(nodes, edges) {
3661
3724
  });
3662
3725
  }
3663
3726
 
3727
+ // src/layout/recursive.ts
3728
+ function buildContainerTree(groups, constraints, edges) {
3729
+ const childrenOf = /* @__PURE__ */ new Map();
3730
+ const parentOf = /* @__PURE__ */ new Map();
3731
+ for (const group of groups) {
3732
+ const children = [];
3733
+ for (const nodeId of group.nodeIds) {
3734
+ children.push(nodeId);
3735
+ parentOf.set(nodeId, group.id);
3736
+ }
3737
+ for (const childGroupId of group.groupIds) {
3738
+ children.push(childGroupId);
3739
+ parentOf.set(childGroupId, group.id);
3740
+ }
3741
+ childrenOf.set(group.id, children);
3742
+ }
3743
+ for (const c of constraints) {
3744
+ if (c.kind !== "containment") continue;
3745
+ for (const childId of c.childIds) {
3746
+ const existing = parentOf.get(childId);
3747
+ if (existing !== void 0) {
3748
+ if (existing === c.containerId) continue;
3749
+ const oldSiblings = childrenOf.get(existing) ?? [];
3750
+ const pruned = oldSiblings.filter((id) => id !== childId);
3751
+ if (pruned.length === 0) {
3752
+ childrenOf.delete(existing);
3753
+ } else {
3754
+ childrenOf.set(existing, pruned);
3755
+ }
3756
+ const newSiblings = childrenOf.get(c.containerId) ?? [];
3757
+ newSiblings.push(childId);
3758
+ childrenOf.set(c.containerId, newSiblings);
3759
+ parentOf.set(childId, c.containerId);
3760
+ } else {
3761
+ const list = childrenOf.get(c.containerId) ?? [];
3762
+ list.push(childId);
3763
+ childrenOf.set(c.containerId, list);
3764
+ parentOf.set(childId, c.containerId);
3765
+ }
3766
+ }
3767
+ }
3768
+ const rootIds = /* @__PURE__ */ new Set();
3769
+ for (const group of groups) {
3770
+ if (!parentOf.has(group.id)) {
3771
+ rootIds.add(group.id);
3772
+ }
3773
+ }
3774
+ const edgesInGroup = /* @__PURE__ */ new Map();
3775
+ for (const edge of edges) {
3776
+ const srcParent = parentOf.get(edge.source.nodeId);
3777
+ const tgtParent = parentOf.get(edge.target.nodeId);
3778
+ if (srcParent !== void 0 && srcParent === tgtParent) {
3779
+ const list = edgesInGroup.get(srcParent) ?? [];
3780
+ list.push(edge);
3781
+ edgesInGroup.set(srcParent, list);
3782
+ }
3783
+ }
3784
+ const treeDiagnostics = [];
3785
+ return { childrenOf, rootIds, edgesInGroup, diagnostics: treeDiagnostics };
3786
+ }
3787
+ function runRecursiveContainerLayout(input) {
3788
+ const diagnostics = [];
3789
+ const boxes = /* @__PURE__ */ new Map();
3790
+ const groupBoxes = /* @__PURE__ */ new Map();
3791
+ const nodeById = new Map(input.nodes.map((n) => [n.id, n]));
3792
+ const groupById = new Map(input.groups.map((g) => [g.id, g]));
3793
+ const groupIdSet = new Set(input.groups.map((g) => g.id));
3794
+ const {
3795
+ childrenOf,
3796
+ rootIds,
3797
+ edgesInGroup,
3798
+ diagnostics: treeDiagnostics
3799
+ } = buildContainerTree(input.groups, input.constraints, input.edges);
3800
+ diagnostics.push(...treeDiagnostics);
3801
+ if (input.groups.length === 0) {
3802
+ const flat = runComponentAwareDagreInitialLayout({
3803
+ direction: input.direction,
3804
+ nodes: input.nodes.map((n) => ({ id: n.id, size: n.size })),
3805
+ edges: input.edges.map((e) => ({
3806
+ id: e.id,
3807
+ sourceId: e.source.nodeId,
3808
+ targetId: e.target.nodeId
3809
+ })),
3810
+ ...input.options === void 0 ? {} : { options: input.options }
3811
+ });
3812
+ diagnostics.push(...flat.diagnostics);
3813
+ for (const [id, box] of flat.boxes) {
3814
+ boxes.set(id, box);
3815
+ }
3816
+ return { boxes, groupBoxes, diagnostics };
3817
+ }
3818
+ const descendants = /* @__PURE__ */ new Map();
3819
+ function collectDescendants(groupId) {
3820
+ const cached = descendants.get(groupId);
3821
+ if (cached !== void 0) return cached;
3822
+ const result = /* @__PURE__ */ new Set();
3823
+ const children = childrenOf.get(groupId) ?? [];
3824
+ for (const childId of children) {
3825
+ result.add(childId);
3826
+ const childDesc = collectDescendants(childId);
3827
+ for (const d of childDesc) result.add(d);
3828
+ }
3829
+ descendants.set(groupId, result);
3830
+ return result;
3831
+ }
3832
+ const groupOrder = topologicalSort(input.groups, childrenOf, groupIdSet);
3833
+ for (const groupId of groupOrder) {
3834
+ const group = groupById.get(groupId);
3835
+ if (group === void 0) continue;
3836
+ const children = childrenOf.get(groupId) ?? [];
3837
+ if (children.length === 0) {
3838
+ const box = {
3839
+ x: 0,
3840
+ y: 0,
3841
+ width: (group.padding?.left ?? 8) + (group.padding?.right ?? 8) + 40,
3842
+ height: (group.padding?.top ?? 8) + (group.padding?.bottom ?? 8) + 20
3843
+ };
3844
+ groupBoxes.set(groupId, box);
3845
+ boxes.set(groupId, box);
3846
+ continue;
3847
+ }
3848
+ const leafNodeIds = [];
3849
+ const nestedGroupIds = [];
3850
+ for (const childId of children) {
3851
+ if (groupIdSet.has(childId)) {
3852
+ nestedGroupIds.push(childId);
3853
+ } else {
3854
+ leafNodeIds.push(childId);
3855
+ }
3856
+ }
3857
+ const childSizes = /* @__PURE__ */ new Map();
3858
+ for (const nodeId of leafNodeIds) {
3859
+ const node = nodeById.get(nodeId);
3860
+ if (node !== void 0) {
3861
+ childSizes.set(nodeId, {
3862
+ width: node.size.width,
3863
+ height: node.size.height
3864
+ });
3865
+ }
3866
+ }
3867
+ for (const nestedId of nestedGroupIds) {
3868
+ const nestedBox = groupBoxes.get(nestedId);
3869
+ if (nestedBox !== void 0) {
3870
+ childSizes.set(nestedId, {
3871
+ width: nestedBox.width,
3872
+ height: nestedBox.height
3873
+ });
3874
+ }
3875
+ }
3876
+ const groupEdges = edgesInGroup.get(groupId) ?? [];
3877
+ const childLayout = runDagreInitialLayout({
3878
+ direction: input.direction,
3879
+ nodes: children.flatMap((childId) => {
3880
+ const size = childSizes.get(childId);
3881
+ return size === void 0 ? [] : [{ id: childId, size }];
3882
+ }),
3883
+ edges: groupEdges.map((e) => ({
3884
+ id: e.id,
3885
+ sourceId: e.source.nodeId,
3886
+ targetId: e.target.nodeId
3887
+ })),
3888
+ options: {
3889
+ ...input.options ?? {},
3890
+ ranksep: (input.options?.ranksep ?? 100) * 0.6,
3891
+ // tighter inside containers
3892
+ nodesep: (input.options?.nodesep ?? 80) * 0.6
3893
+ }
3894
+ });
3895
+ diagnostics.push(...childLayout.diagnostics);
3896
+ if (childLayout.boxes.size === 0) continue;
3897
+ const childBoxes = [...childLayout.boxes.values()];
3898
+ const contentBounds = unionBoxes(childBoxes);
3899
+ const padding = group.padding ?? {
3900
+ top: 8,
3901
+ right: 8,
3902
+ bottom: 8,
3903
+ left: 8
3904
+ };
3905
+ const containerBox = {
3906
+ x: 0,
3907
+ y: 0,
3908
+ width: contentBounds.width + (padding.left ?? 8) + (padding.right ?? 8),
3909
+ height: contentBounds.height + (padding.top ?? 8) + (padding.bottom ?? 8)
3910
+ };
3911
+ const offsetX = padding.left ?? 8;
3912
+ const offsetY = padding.top ?? 8;
3913
+ for (const [childId, childBox] of childLayout.boxes) {
3914
+ boxes.set(childId, {
3915
+ ...childBox,
3916
+ x: childBox.x + offsetX,
3917
+ y: childBox.y + offsetY
3918
+ });
3919
+ }
3920
+ groupBoxes.set(groupId, containerBox);
3921
+ boxes.set(groupId, containerBox);
3922
+ }
3923
+ const allContainedIds = /* @__PURE__ */ new Set();
3924
+ for (const [, childIds] of childrenOf) {
3925
+ for (const cid of childIds) allContainedIds.add(cid);
3926
+ }
3927
+ const topLevelNodeIds = /* @__PURE__ */ new Set();
3928
+ for (const node of input.nodes) {
3929
+ if (!allContainedIds.has(node.id)) {
3930
+ topLevelNodeIds.add(node.id);
3931
+ }
3932
+ }
3933
+ const globalNodes = [];
3934
+ for (const nodeId of topLevelNodeIds) {
3935
+ const node = nodeById.get(nodeId);
3936
+ if (node !== void 0) {
3937
+ globalNodes.push({ id: nodeId, size: node.size });
3938
+ }
3939
+ }
3940
+ for (const rootId of rootIds) {
3941
+ const gb = groupBoxes.get(rootId);
3942
+ if (gb !== void 0) {
3943
+ globalNodes.push({
3944
+ id: rootId,
3945
+ size: { width: gb.width, height: gb.height }
3946
+ });
3947
+ }
3948
+ }
3949
+ function rootContainerOf(id) {
3950
+ for (const rootId of rootIds) {
3951
+ const desc = collectDescendants(rootId);
3952
+ if (desc.has(id)) return rootId;
3953
+ }
3954
+ return void 0;
3955
+ }
3956
+ const globalEdges = input.edges.filter((e) => {
3957
+ for (const group of input.groups) {
3958
+ const desc = collectDescendants(group.id);
3959
+ if (desc.has(e.source.nodeId) && desc.has(e.target.nodeId)) {
3960
+ return false;
3961
+ }
3962
+ }
3963
+ return true;
3964
+ }).map((e) => ({
3965
+ id: e.id,
3966
+ sourceId: rootContainerOf(e.source.nodeId) ?? e.source.nodeId,
3967
+ targetId: rootContainerOf(e.target.nodeId) ?? e.target.nodeId
3968
+ }));
3969
+ if (globalNodes.length > 0) {
3970
+ const globalLayout = runDagreInitialLayout({
3971
+ direction: input.direction,
3972
+ nodes: globalNodes,
3973
+ edges: globalEdges,
3974
+ ...input.options === void 0 ? {} : { options: input.options }
3975
+ });
3976
+ diagnostics.push(...globalLayout.diagnostics);
3977
+ for (const [id, box] of globalLayout.boxes) {
3978
+ if (groupBoxes.has(id)) {
3979
+ groupBoxes.set(id, box);
3980
+ boxes.set(id, box);
3981
+ } else if (topLevelNodeIds.has(id)) {
3982
+ boxes.set(id, box);
3983
+ }
3984
+ }
3985
+ for (const groupId of groupOrder) {
3986
+ const containerBox = groupBoxes.get(groupId);
3987
+ if (containerBox === void 0) continue;
3988
+ const offsetX = containerBox.x;
3989
+ const offsetY = containerBox.y;
3990
+ const children = childrenOf.get(groupId) ?? [];
3991
+ for (const childId of children) {
3992
+ const childBox = boxes.get(childId);
3993
+ if (childBox !== void 0) {
3994
+ boxes.set(childId, {
3995
+ ...childBox,
3996
+ x: childBox.x + offsetX,
3997
+ y: childBox.y + offsetY
3998
+ });
3999
+ }
4000
+ translateDescendants(childId, offsetX, offsetY, boxes, childrenOf);
4001
+ }
4002
+ }
4003
+ }
4004
+ return { boxes, groupBoxes, diagnostics };
4005
+ }
4006
+ function translateDescendants(groupId, dx, dy, boxes, childrenOf) {
4007
+ const children = childrenOf.get(groupId) ?? [];
4008
+ for (const childId of children) {
4009
+ const box = boxes.get(childId);
4010
+ if (box !== void 0) {
4011
+ boxes.set(childId, { ...box, x: box.x + dx, y: box.y + dy });
4012
+ }
4013
+ translateDescendants(childId, dx, dy, boxes, childrenOf);
4014
+ }
4015
+ }
4016
+ function topologicalSort(groups, childrenOf, groupIdSet) {
4017
+ const visited = /* @__PURE__ */ new Set();
4018
+ const result = [];
4019
+ function visit(id) {
4020
+ if (visited.has(id)) return;
4021
+ visited.add(id);
4022
+ const children = childrenOf.get(id) ?? [];
4023
+ for (const childId of children) {
4024
+ if (groupIdSet.has(childId)) {
4025
+ visit(childId);
4026
+ }
4027
+ }
4028
+ result.push(id);
4029
+ }
4030
+ for (const group of groups) {
4031
+ visit(group.id);
4032
+ }
4033
+ return result;
4034
+ }
4035
+
4036
+ // src/routing/binary-heap.ts
4037
+ var BinaryHeap = class {
4038
+ _data = [];
4039
+ _nextOrder = 0;
4040
+ get size() {
4041
+ return this._data.length;
4042
+ }
4043
+ push(value, priority) {
4044
+ const entry = { value, priority, order: this._nextOrder++ };
4045
+ this._data.push(entry);
4046
+ this._siftUp(this._data.length - 1);
4047
+ }
4048
+ pop() {
4049
+ if (this._data.length === 0) return void 0;
4050
+ const top = this._data[0];
4051
+ const last = this._data.pop();
4052
+ if (this._data.length > 0) {
4053
+ this._data[0] = last;
4054
+ this._siftDown(0);
4055
+ }
4056
+ return top.value;
4057
+ }
4058
+ peek() {
4059
+ return this._data.length > 0 ? this._data[0].value : void 0;
4060
+ }
4061
+ // -----------------------------------------------------------------------
4062
+ // Internals
4063
+ // -----------------------------------------------------------------------
4064
+ _siftUp(idx) {
4065
+ const entry = this._data[idx];
4066
+ while (idx > 0) {
4067
+ const parentIdx = idx - 1 >> 1;
4068
+ const parent = this._data[parentIdx];
4069
+ if (this._less(entry, parent)) {
4070
+ this._data[idx] = parent;
4071
+ idx = parentIdx;
4072
+ } else {
4073
+ break;
4074
+ }
4075
+ }
4076
+ this._data[idx] = entry;
4077
+ }
4078
+ _siftDown(idx) {
4079
+ const entry = this._data[idx];
4080
+ const size = this._data.length;
4081
+ while (true) {
4082
+ let smallestIdx = idx;
4083
+ const leftIdx = (idx << 1) + 1;
4084
+ const rightIdx = leftIdx + 1;
4085
+ if (leftIdx < size && this._less(
4086
+ this._data[leftIdx],
4087
+ this._data[smallestIdx]
4088
+ )) {
4089
+ smallestIdx = leftIdx;
4090
+ }
4091
+ if (rightIdx < size && this._less(
4092
+ this._data[rightIdx],
4093
+ this._data[smallestIdx]
4094
+ )) {
4095
+ smallestIdx = rightIdx;
4096
+ }
4097
+ if (smallestIdx !== idx) {
4098
+ this._data[idx] = this._data[smallestIdx];
4099
+ idx = smallestIdx;
4100
+ } else {
4101
+ break;
4102
+ }
4103
+ }
4104
+ this._data[idx] = entry;
4105
+ }
4106
+ /**
4107
+ * Two entries are compared first by priority, then by insertion order
4108
+ * when priorities are equal. The insertion-order tie-break makes the
4109
+ * heap deterministic: for a given sequence of {value, priority} pushes,
4110
+ * the extraction order is always the same.
4111
+ */
4112
+ _less(a, b) {
4113
+ if (a.priority !== b.priority) return a.priority < b.priority;
4114
+ return a.order < b.order;
4115
+ }
4116
+ };
4117
+
3664
4118
  // src/routing/astar.ts
3665
4119
  function findObstacleFreePath(source, target, obstacles, options = {}, diagnostics) {
3666
4120
  const margin = options.margin ?? 0;
@@ -3668,8 +4122,17 @@ function findObstacleFreePath(source, target, obstacles, options = {}, diagnosti
3668
4122
  const segmentPenalty = options.segmentPenalty ?? 1;
3669
4123
  const endpointObstacles = options.endpointObstacles ?? [];
3670
4124
  const maxNodes = options.maxNodes ?? (obstacles.length > 30 ? 16e3 : 4e3);
3671
- const xs = collectXs(source, target, obstacles, margin);
3672
- const ys = collectYs(source, target, obstacles, margin);
4125
+ const useCorridor = options.corridorPrefilter ?? true;
4126
+ const corridorMargin = options.corridorMargin ?? 32;
4127
+ const filtered = useCorridor ? filterObstaclesByCorridor(
4128
+ source,
4129
+ target,
4130
+ obstacles,
4131
+ endpointObstacles,
4132
+ corridorMargin
4133
+ ) : obstacles;
4134
+ const xs = collectXs(source, target, filtered, margin);
4135
+ const ys = collectYs(source, target, filtered, margin);
3673
4136
  if (xs.length * ys.length > maxNodes) {
3674
4137
  diagnostics?.push({
3675
4138
  severity: "warning",
@@ -3684,8 +4147,8 @@ function findObstacleFreePath(source, target, obstacles, options = {}, diagnosti
3684
4147
  return null;
3685
4148
  }
3686
4149
  const { nodes, nodeIndex } = buildGraph(xs, ys);
3687
- connectHorizontalEdges(nodes, ys, obstacles, endpointObstacles, margin);
3688
- connectVerticalEdges(nodes, xs, obstacles, endpointObstacles, margin);
4150
+ connectHorizontalEdges(nodes, ys, filtered, endpointObstacles, margin);
4151
+ connectVerticalEdges(nodes, xs, filtered, endpointObstacles, margin);
3689
4152
  const path = aStarSearch(
3690
4153
  nodes,
3691
4154
  nodeIndex,
@@ -3697,6 +4160,22 @@ function findObstacleFreePath(source, target, obstacles, options = {}, diagnosti
3697
4160
  if (path === null) return null;
3698
4161
  return simplifyRoute(path);
3699
4162
  }
4163
+ function filterObstaclesByCorridor(source, target, obstacles, endpointObstacles, margin) {
4164
+ const cx1 = Math.min(source.x, target.x) - margin;
4165
+ const cx2 = Math.max(source.x, target.x) + margin;
4166
+ const cy1 = Math.min(source.y, target.y) - margin;
4167
+ const cy2 = Math.max(source.y, target.y) + margin;
4168
+ const result = [];
4169
+ for (const obs of obstacles) {
4170
+ if (obs.x + obs.width >= cx1 && obs.x <= cx2 && obs.y + obs.height >= cy1 && obs.y <= cy2) {
4171
+ result.push(obs);
4172
+ }
4173
+ }
4174
+ for (const ep of endpointObstacles) {
4175
+ result.push(ep);
4176
+ }
4177
+ return result;
4178
+ }
3700
4179
  function collectXs(source, target, obstacles, margin) {
3701
4180
  const raw = [];
3702
4181
  for (const obs of obstacles) {
@@ -3840,25 +4319,17 @@ function aStarSearch(nodes, nodeIndex, source, target, turnPenalty, segmentPenal
3840
4319
  gScore.set(startId, 0);
3841
4320
  const cameFrom = /* @__PURE__ */ new Map();
3842
4321
  const cameFromDir = /* @__PURE__ */ new Map();
3843
- const openSet = [];
3844
- openSet.push({
3845
- id: startId,
3846
- f: manhattan(source, target)
3847
- });
3848
- while (openSet.length > 0) {
3849
- let bestIdx = 0;
3850
- for (let i = 1; i < openSet.length; i++) {
3851
- if (openSet[i].f < openSet[bestIdx].f) {
3852
- bestIdx = i;
3853
- }
3854
- }
3855
- const current = openSet.splice(bestIdx, 1)[0];
3856
- if (current.id === goalId) {
4322
+ const openSet = new BinaryHeap();
4323
+ openSet.push(startId, manhattan(source, target));
4324
+ while (openSet.size > 0) {
4325
+ const currentId = openSet.pop();
4326
+ const currentG = gScore.get(currentId);
4327
+ if (currentG === void 0) continue;
4328
+ if (currentId === goalId) {
3857
4329
  return reconstructPath(nodes, cameFrom, goalId);
3858
4330
  }
3859
- const node = nodes[current.id];
3860
- const currentG = gScore.get(current.id);
3861
- const prevDir = cameFromDir.get(current.id);
4331
+ const node = nodes[currentId];
4332
+ const prevDir = cameFromDir.get(currentId);
3862
4333
  for (const [neighborId, edgeCost] of node.neighbors) {
3863
4334
  const neighbor = nodes[neighborId];
3864
4335
  const tentativeG = currentG + edgeCost * segmentPenalty;
@@ -3868,10 +4339,10 @@ function aStarSearch(nodes, nodeIndex, source, target, turnPenalty, segmentPenal
3868
4339
  const existingG = gScore.get(neighborId);
3869
4340
  if (existingG === void 0 || totalG < existingG) {
3870
4341
  gScore.set(neighborId, totalG);
3871
- cameFrom.set(neighborId, current.id);
4342
+ cameFrom.set(neighborId, currentId);
3872
4343
  cameFromDir.set(neighborId, newDir);
3873
4344
  const f = totalG + manhattan(neighbor, target);
3874
- openSet.push({ id: neighborId, f });
4345
+ openSet.push(neighborId, f);
3875
4346
  }
3876
4347
  }
3877
4348
  }
@@ -3908,6 +4379,292 @@ function areCollinear(a, b, c) {
3908
4379
  return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
3909
4380
  }
3910
4381
 
4382
+ // src/routing/visibility-router.ts
4383
+ function findCornerGraphPath(source, target, obstacles, options = {}, diagnostics) {
4384
+ const margin = options.margin ?? 0;
4385
+ const turnPenalty = options.turnPenalty ?? 50;
4386
+ const segmentPenalty = options.segmentPenalty ?? 1;
4387
+ const endpointObstacles = options.endpointObstacles ?? [];
4388
+ const maxCorners = options.maxCorners ?? 300;
4389
+ const vertices = collectCornerVertices(source, target, obstacles, margin);
4390
+ if (vertices.length > maxCorners) {
4391
+ diagnostics?.push({
4392
+ severity: "warning",
4393
+ code: "routing.visibility.corner_overflow",
4394
+ message: `Corner graph overflow: ${vertices.length} vertices > ${maxCorners}. Falling back to grid A*.`,
4395
+ detail: { vertexCount: vertices.length, maxCorners }
4396
+ });
4397
+ return null;
4398
+ }
4399
+ if (obstacles.length === 0) {
4400
+ return simplifyRoute2([source, target]);
4401
+ }
4402
+ const expandedObstacles = margin === 0 ? obstacles : obstacles.map((o) => expandBox2(o, margin));
4403
+ const edges = buildVisibilityEdges(
4404
+ vertices,
4405
+ expandedObstacles,
4406
+ endpointObstacles
4407
+ );
4408
+ const path = aStarSearch2(
4409
+ vertices,
4410
+ edges,
4411
+ source,
4412
+ target,
4413
+ segmentPenalty,
4414
+ turnPenalty
4415
+ );
4416
+ if (path === null) return null;
4417
+ return simplifyRoute2(path, expandedObstacles);
4418
+ }
4419
+ function collectCornerVertices(source, target, obstacles, margin = 0) {
4420
+ const vertices = [
4421
+ { point: { x: source.x, y: source.y }, obstacleIndex: -1 },
4422
+ { point: { x: target.x, y: target.y }, obstacleIndex: -1 }
4423
+ ];
4424
+ const seen = /* @__PURE__ */ new Set();
4425
+ seen.add(`${source.x},${source.y}`);
4426
+ seen.add(`${target.x},${target.y}`);
4427
+ const addVertex = (p, obstacleIndex) => {
4428
+ const key = `${p.x},${p.y}`;
4429
+ if (seen.has(key)) return;
4430
+ seen.add(key);
4431
+ vertices.push({ point: p, obstacleIndex });
4432
+ };
4433
+ for (let i = 0; i < obstacles.length; i++) {
4434
+ const obs = obstacles[i];
4435
+ const c0 = { x: obs.x - margin, y: obs.y - margin };
4436
+ const c1 = { x: obs.x + obs.width + margin, y: obs.y - margin };
4437
+ const c2 = {
4438
+ x: obs.x + obs.width + margin,
4439
+ y: obs.y + obs.height + margin
4440
+ };
4441
+ const c3 = { x: obs.x - margin, y: obs.y + obs.height + margin };
4442
+ for (const c of [c0, c1, c2, c3]) {
4443
+ addVertex(c, i);
4444
+ }
4445
+ }
4446
+ for (const p of [source, target]) {
4447
+ for (let i = 0; i < obstacles.length; i++) {
4448
+ const obs = obstacles[i];
4449
+ addVertex({ x: obs.x - margin, y: p.y }, i);
4450
+ addVertex({ x: obs.x + obs.width + margin, y: p.y }, i);
4451
+ addVertex({ x: p.x, y: obs.y - margin }, i);
4452
+ addVertex({ x: p.x, y: obs.y + obs.height + margin }, i);
4453
+ if (p.x > obs.x && p.x < obs.x + obs.width) {
4454
+ addVertex({ x: p.x, y: obs.y - margin }, i);
4455
+ addVertex({ x: p.x, y: obs.y + obs.height + margin }, i);
4456
+ }
4457
+ if (p.y > obs.y && p.y < obs.y + obs.height) {
4458
+ addVertex({ x: obs.x - margin, y: p.y }, i);
4459
+ addVertex({ x: obs.x + obs.width + margin, y: p.y }, i);
4460
+ }
4461
+ }
4462
+ }
4463
+ return vertices;
4464
+ }
4465
+ function buildVisibilityEdges(vertices, obstacles, endpointObstacles) {
4466
+ const edges = [];
4467
+ for (let i = 0; i < vertices.length; i++) {
4468
+ const v = vertices[i];
4469
+ const right = visibleInDirection(
4470
+ v,
4471
+ vertices,
4472
+ obstacles,
4473
+ endpointObstacles,
4474
+ "right"
4475
+ );
4476
+ const left = visibleInDirection(
4477
+ v,
4478
+ vertices,
4479
+ obstacles,
4480
+ endpointObstacles,
4481
+ "left"
4482
+ );
4483
+ const down = visibleInDirection(
4484
+ v,
4485
+ vertices,
4486
+ obstacles,
4487
+ endpointObstacles,
4488
+ "down"
4489
+ );
4490
+ const up = visibleInDirection(
4491
+ v,
4492
+ vertices,
4493
+ obstacles,
4494
+ endpointObstacles,
4495
+ "up"
4496
+ );
4497
+ for (const neighbor of [right, left, down, up]) {
4498
+ if (neighbor !== null && neighbor.index > i) {
4499
+ edges.push({
4500
+ from: i,
4501
+ to: neighbor.index,
4502
+ cost: neighbor.distance
4503
+ });
4504
+ }
4505
+ }
4506
+ }
4507
+ return edges;
4508
+ }
4509
+ function visibleInDirection(origin, vertices, obstacles, endpointObstacles, dir) {
4510
+ const candidates = [];
4511
+ for (let i = 0; i < vertices.length; i++) {
4512
+ const v = vertices[i];
4513
+ const dx = v.point.x - origin.point.x;
4514
+ const dy = v.point.y - origin.point.y;
4515
+ switch (dir) {
4516
+ case "right":
4517
+ if (dx > 0 && dy === 0) candidates.push({ index: i, dist: dx });
4518
+ break;
4519
+ case "left":
4520
+ if (dx < 0 && dy === 0) candidates.push({ index: i, dist: -dx });
4521
+ break;
4522
+ case "down":
4523
+ if (dy > 0 && dx === 0) candidates.push({ index: i, dist: dy });
4524
+ break;
4525
+ case "up":
4526
+ if (dy < 0 && dx === 0) candidates.push({ index: i, dist: -dy });
4527
+ break;
4528
+ }
4529
+ }
4530
+ candidates.sort((a, b) => a.dist - b.dist);
4531
+ for (const c of candidates) {
4532
+ if (isSegmentVisible(
4533
+ origin.point,
4534
+ vertices[c.index].point,
4535
+ obstacles,
4536
+ endpointObstacles,
4537
+ origin.obstacleIndex,
4538
+ vertices[c.index].obstacleIndex
4539
+ )) {
4540
+ return { index: c.index, distance: c.dist };
4541
+ }
4542
+ }
4543
+ return null;
4544
+ }
4545
+ function isSegmentVisible(a, b, obstacles, endpointObstacles, _aObsIdx, _bObsIdx) {
4546
+ for (let i = 0; i < obstacles.length; i++) {
4547
+ if (segmentEntersBox(a, b, obstacles[i])) return false;
4548
+ }
4549
+ for (const ep of endpointObstacles) {
4550
+ if (segmentEntersBox(a, b, ep)) return false;
4551
+ }
4552
+ return true;
4553
+ }
4554
+ function segmentEntersBox(start, end, box) {
4555
+ const left = box.x;
4556
+ const right = box.x + box.width;
4557
+ const top = box.y;
4558
+ const bottom = box.y + box.height;
4559
+ const inside = (p) => p.x > left && p.x < right && p.y > top && p.y < bottom;
4560
+ if (inside(start) || inside(end)) return true;
4561
+ if (start.x === end.x) {
4562
+ return start.x > left && start.x < right && Math.max(start.y, end.y) > top && Math.min(start.y, end.y) < bottom;
4563
+ }
4564
+ if (start.y === end.y) {
4565
+ return start.y > top && start.y < bottom && Math.max(start.x, end.x) > left && Math.min(start.x, end.x) < right;
4566
+ }
4567
+ return false;
4568
+ }
4569
+ function expandBox2(box, margin) {
4570
+ return {
4571
+ x: box.x - margin,
4572
+ y: box.y - margin,
4573
+ width: box.width + margin * 2,
4574
+ height: box.height + margin * 2
4575
+ };
4576
+ }
4577
+ function aStarSearch2(vertices, edges, source, target, segmentPenalty, turnPenalty) {
4578
+ const startId = 0;
4579
+ const goalId = 1;
4580
+ const gScore = /* @__PURE__ */ new Map();
4581
+ gScore.set(startId, 0);
4582
+ const cameFrom = /* @__PURE__ */ new Map();
4583
+ const cameFromDir = /* @__PURE__ */ new Map();
4584
+ const openSet = new BinaryHeap();
4585
+ openSet.push(startId, manhattan2(source, target));
4586
+ const neighborMap = /* @__PURE__ */ new Map();
4587
+ for (const e of edges) {
4588
+ let list = neighborMap.get(e.from);
4589
+ if (list === void 0) {
4590
+ list = [];
4591
+ neighborMap.set(e.from, list);
4592
+ }
4593
+ list.push({ to: e.to, cost: e.cost });
4594
+ list = neighborMap.get(e.to);
4595
+ if (list === void 0) {
4596
+ list = [];
4597
+ neighborMap.set(e.to, list);
4598
+ }
4599
+ list.push({ to: e.from, cost: e.cost });
4600
+ }
4601
+ while (openSet.size > 0) {
4602
+ const currentId = openSet.pop();
4603
+ const currentG = gScore.get(currentId);
4604
+ if (currentG === void 0) continue;
4605
+ if (currentId === goalId) {
4606
+ return reconstructPath2(vertices, cameFrom, goalId);
4607
+ }
4608
+ const prevDir = cameFromDir.get(currentId);
4609
+ const neighbors = neighborMap.get(currentId) ?? [];
4610
+ for (const { to, cost } of neighbors) {
4611
+ const tentativeG = currentG + cost * segmentPenalty;
4612
+ const toV = vertices[to];
4613
+ const curV = vertices[currentId];
4614
+ const newDir = toV.point.y === curV.point.y ? "h" : "v";
4615
+ const turnCost = prevDir !== void 0 && prevDir !== newDir ? turnPenalty : 0;
4616
+ const totalG = tentativeG + turnCost;
4617
+ const existingG = gScore.get(to);
4618
+ if (existingG === void 0 || totalG < existingG) {
4619
+ gScore.set(to, totalG);
4620
+ cameFrom.set(to, currentId);
4621
+ cameFromDir.set(to, newDir);
4622
+ const f = totalG + manhattan2(toV.point, target);
4623
+ openSet.push(to, f);
4624
+ }
4625
+ }
4626
+ }
4627
+ return null;
4628
+ }
4629
+ function manhattan2(a, b) {
4630
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
4631
+ }
4632
+ function reconstructPath2(vertices, cameFrom, goalId) {
4633
+ const path = [];
4634
+ let current = goalId;
4635
+ while (current !== void 0) {
4636
+ const v = vertices[current];
4637
+ path.unshift({ x: v.point.x, y: v.point.y });
4638
+ current = cameFrom.get(current);
4639
+ }
4640
+ return path;
4641
+ }
4642
+ function simplifyRoute2(points, obstacles = []) {
4643
+ if (points.length <= 2) return [...points];
4644
+ const result = [points[0]];
4645
+ for (let i = 1; i < points.length - 1; i++) {
4646
+ const prev = result[result.length - 1];
4647
+ const curr = points[i];
4648
+ const next = points[i + 1];
4649
+ const collinear = prev.x === curr.x && curr.x === next.x || prev.y === curr.y && curr.y === next.y;
4650
+ if (!collinear) {
4651
+ result.push(curr);
4652
+ continue;
4653
+ }
4654
+ if (segmentCrossesAnyObstacle(prev, next, obstacles)) {
4655
+ result.push(curr);
4656
+ }
4657
+ }
4658
+ result.push(points[points.length - 1]);
4659
+ return result;
4660
+ }
4661
+ function segmentCrossesAnyObstacle(a, b, obstacles) {
4662
+ for (const obs of obstacles) {
4663
+ if (segmentEntersBox(a, b, obs)) return true;
4664
+ }
4665
+ return false;
4666
+ }
4667
+
3911
4668
  // src/routing/routes.ts
3912
4669
  function checkBacktracking(points, source, target, diagnostics) {
3913
4670
  if (points.length < 2) return;
@@ -3997,13 +4754,18 @@ function routeEdge(input) {
3997
4754
  input.source.center,
3998
4755
  targetAnchor
3999
4756
  );
4000
- const path = findObstacleFreePath(
4757
+ const cornerPath = findCornerGraphPath(
4001
4758
  source,
4002
4759
  target,
4003
4760
  [...softObstacles, ...hardObstacles],
4004
- {
4005
- endpointObstacles
4006
- },
4761
+ { endpointObstacles, margin: 2 },
4762
+ diagnostics
4763
+ );
4764
+ const path = cornerPath ?? findObstacleFreePath(
4765
+ source,
4766
+ target,
4767
+ [...softObstacles, ...hardObstacles],
4768
+ { endpointObstacles, margin: 0 },
4007
4769
  diagnostics
4008
4770
  );
4009
4771
  if (path !== null && path.length >= 2) {
@@ -4246,7 +5008,7 @@ function routeEdge(input) {
4246
5008
  };
4247
5009
  }
4248
5010
  function finalizeRoute(points, softObstacles, hardObstacles, diagnostics, softObstacleIndex, hardObstacleIndex) {
4249
- const simplified = simplifyRoute2(points);
5011
+ const simplified = simplifyRoute3(points);
4250
5012
  if (simplified.length >= 3) {
4251
5013
  return simplified;
4252
5014
  }
@@ -4550,7 +5312,7 @@ function squaredDistance2(a, b) {
4550
5312
  const dy = a.y - b.y;
4551
5313
  return dx * dx + dy * dy;
4552
5314
  }
4553
- function simplifyRoute2(points) {
5315
+ function simplifyRoute3(points) {
4554
5316
  const withoutDuplicates = [];
4555
5317
  for (const point2 of points) {
4556
5318
  const previous = withoutDuplicates.at(-1);
@@ -4890,7 +5652,21 @@ function solveDiagram(diagram, options = {}) {
4890
5652
  );
4891
5653
  const constraints = stableByConstraintId(diagram.constraints);
4892
5654
  const initialLayoutMode = options.initialLayout ?? "dagre";
4893
- const layout2 = runInitialLayout({
5655
+ const useRecursive = options.recursiveLayout === true;
5656
+ if (useRecursive && initialLayoutMode === "positions") {
5657
+ diagnostics.push({
5658
+ severity: "warning",
5659
+ code: "layout.recursive-ignores-positions",
5660
+ message: 'recursiveLayout overrides initialLayout "positions" \u2014 seed positions are ignored for bottom-up container layout.'
5661
+ });
5662
+ }
5663
+ const layout2 = useRecursive ? runRecursiveContainerLayout({
5664
+ direction: diagram.direction,
5665
+ nodes: styledNodes,
5666
+ groups: styledGroups,
5667
+ edges: styledEdges,
5668
+ constraints
5669
+ }) : runInitialLayout({
4894
5670
  mode: initialLayoutMode,
4895
5671
  componentAware: options.maxStackDepth === void 0,
4896
5672
  direction: diagram.direction,
@@ -4906,12 +5682,32 @@ function solveDiagram(diagram, options = {}) {
4906
5682
  options,
4907
5683
  diagnostics
4908
5684
  );
5685
+ if ((diagram.direction === "TB" || diagram.direction === "BT") && options.maxRowDepth !== void 0) {
5686
+ const rewrapped = wrapHorizontalStackIfNeeded(
5687
+ initialNodeBoxes,
5688
+ styledNodes,
5689
+ diagram.direction,
5690
+ options,
5691
+ diagnostics
5692
+ );
5693
+ for (const [id, box] of rewrapped) {
5694
+ initialNodeBoxes.set(id, box);
5695
+ }
5696
+ }
5697
+ if (useRecursive && "groupBoxes" in layout2) {
5698
+ const recursiveLayout = layout2;
5699
+ for (const [groupId, groupBox] of recursiveLayout.groupBoxes) {
5700
+ initialNodeBoxes.set(groupId, groupBox);
5701
+ }
5702
+ }
4909
5703
  expandNodeBoxesForPorts(styledNodes, initialNodeBoxes, options, diagnostics);
4910
5704
  const constrained = applyLayoutConstraints({
4911
5705
  direction: diagram.direction,
4912
5706
  overlapSpacing: options?.overlapSpacing ?? 40,
4913
5707
  ...options.minSiblingGap === void 0 ? {} : { minSiblingGap: options.minSiblingGap },
4914
5708
  distributeContainedChildren: options.distributeContainedChildren ?? true,
5709
+ distributeSwimlaneChildren: options.distributeSwimlaneChildren ?? "spread",
5710
+ swimlanes: styledSwimlanes,
4915
5711
  boxes: initialNodeBoxes,
4916
5712
  nodes: styledNodes,
4917
5713
  constraints
@@ -5571,7 +6367,7 @@ function wrapVerticalStackIfNeeded(boxes, nodes, edges, direction, options, diag
5571
6367
  );
5572
6368
  return wrapped;
5573
6369
  }
5574
- if (edges.length > 0 || !isVerticalRunaway(wrapped, nodes, direction, options)) {
6370
+ if (edges.length > 0 || !isStackRunaway(wrapped, nodes, direction, options)) {
5575
6371
  reportVerticalRunaway(
5576
6372
  wrapped,
5577
6373
  nodes,
@@ -5622,8 +6418,57 @@ function wrapVerticalStackIfNeeded(boxes, nodes, edges, direction, options, diag
5622
6418
  });
5623
6419
  return wrapped;
5624
6420
  }
6421
+ function wrapHorizontalStackIfNeeded(boxes, nodes, direction, options, diagnostics) {
6422
+ if (!isStackRunaway(boxes, nodes, direction, options)) {
6423
+ return new Map(boxes);
6424
+ }
6425
+ const maxRowDepth = options.maxRowDepth;
6426
+ if (maxRowDepth === void 0 || nodes.length <= maxRowDepth) {
6427
+ return new Map(boxes);
6428
+ }
6429
+ const ordered = [...nodes].sort((a, b) => {
6430
+ const ba = boxes.get(a.id);
6431
+ const bb = boxes.get(b.id);
6432
+ if (ba === void 0 || bb === void 0) return 0;
6433
+ const dx = ba.x - bb.x;
6434
+ return dx !== 0 ? dx : ba.y - bb.y;
6435
+ });
6436
+ const rows = Math.ceil(ordered.length / maxRowDepth);
6437
+ const wrapped = new Map(boxes);
6438
+ const rowSpacing = options.overlapSpacing ?? 40;
6439
+ let minX = Infinity;
6440
+ let minY = Infinity;
6441
+ let maxH = 0;
6442
+ for (const n of ordered) {
6443
+ const b = boxes.get(n.id);
6444
+ if (b !== void 0) {
6445
+ minX = Math.min(minX, b.x);
6446
+ minY = Math.min(minY, b.y);
6447
+ maxH = Math.max(maxH, b.height);
6448
+ }
6449
+ }
6450
+ for (let ri = 0; ri < rows; ri++) {
6451
+ const rowNodes = ordered.slice(ri * maxRowDepth, (ri + 1) * maxRowDepth);
6452
+ let x = minX;
6453
+ const y = minY + ri * (maxH + rowSpacing);
6454
+ for (const node of rowNodes) {
6455
+ const box = boxes.get(node.id);
6456
+ if (box === void 0) continue;
6457
+ wrapped.set(node.id, { ...box, x, y });
6458
+ x += box.width + rowSpacing;
6459
+ }
6460
+ }
6461
+ diagnostics.push({
6462
+ severity: "warning",
6463
+ code: "horizontal_runaway",
6464
+ message: `Single-row layout exceeded maxRowDepth ${maxRowDepth}; wrapped into ${rows} rows.`,
6465
+ path: ["nodes"],
6466
+ detail: { nodeCount: ordered.length, maxRowDepth, rows }
6467
+ });
6468
+ return wrapped;
6469
+ }
5625
6470
  function reportVerticalRunaway(boxes, nodes, edges, direction, options, diagnostics) {
5626
- if (!isVerticalRunaway(boxes, nodes, direction, options)) {
6471
+ if (!isStackRunaway(boxes, nodes, direction, options)) {
5627
6472
  return;
5628
6473
  }
5629
6474
  diagnostics.push({
@@ -5639,7 +6484,7 @@ function reportVerticalRunaway(boxes, nodes, edges, direction, options, diagnost
5639
6484
  }
5640
6485
  });
5641
6486
  }
5642
- function isVerticalRunaway(boxes, nodes, direction, options) {
6487
+ function isStackRunaway(boxes, nodes, direction, options) {
5643
6488
  if (options.maxStackDepth === void 0 && options.preferredAspectRatio === void 0) {
5644
6489
  return false;
5645
6490
  }
@@ -6230,7 +7075,7 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
6230
7075
  });
6231
7076
  continue;
6232
7077
  }
6233
- const ports = node.ports === void 0 ? void 0 : coordinatePorts(node, box, options.portShifting);
7078
+ const ports = node.ports === void 0 ? void 0 : coordinatePorts(node, box, options.portShifting, diagnostics);
6234
7079
  const geometry = computeShapeGeometry({
6235
7080
  shape: node.shape,
6236
7081
  box,
@@ -6324,7 +7169,7 @@ function expandNodeBoxesForPorts(nodes, boxes, options, diagnostics) {
6324
7169
  }
6325
7170
  }
6326
7171
  }
6327
- function coordinatePorts(node, nodeBox, portShifting) {
7172
+ function coordinatePorts(node, nodeBox, portShifting, diagnostics) {
6328
7173
  const portsBySide = /* @__PURE__ */ new Map();
6329
7174
  for (const port of node.ports ?? []) {
6330
7175
  const ports = portsBySide.get(port.side) ?? [];
@@ -6347,7 +7192,9 @@ function coordinatePorts(node, nodeBox, portShifting) {
6347
7192
  side,
6348
7193
  index,
6349
7194
  sorted.length,
6350
- portShifting
7195
+ portShifting,
7196
+ diagnostics,
7197
+ node.id
6351
7198
  );
6352
7199
  const box = portBox(anchor);
6353
7200
  coordinated.push({ ...port, box, anchor });
@@ -6355,16 +7202,32 @@ function coordinatePorts(node, nodeBox, portShifting) {
6355
7202
  }
6356
7203
  return coordinated.sort((a, b) => a.id.localeCompare(b.id));
6357
7204
  }
6358
- function portAnchor(nodeBox, side, index, count, portShifting) {
7205
+ function portAnchor(nodeBox, side, index, count, portShifting, diagnostics, nodeId) {
6359
7206
  const shiftingEnabled = portShifting?.enabled ?? true;
6360
7207
  const requestedSpacing = portShifting?.spacing ?? 24;
6361
7208
  const maxOffset = side === "left" || side === "right" ? nodeBox.height / 2 : nodeBox.width / 2;
6362
7209
  const availableSpan = 2 * maxOffset;
6363
7210
  const minSpacing = PORT_BOX_SIZE + MIN_PORT_EDGE_GAP;
6364
- const spacing = shiftingEnabled && count > 1 ? Math.max(
7211
+ const effectiveSpacing = shiftingEnabled && count > 1 ? Math.max(
6365
7212
  Math.min(requestedSpacing, availableSpan / (count - 1)),
6366
7213
  minSpacing
6367
7214
  ) : requestedSpacing;
7215
+ if (shiftingEnabled && count > 1 && effectiveSpacing < requestedSpacing && diagnostics !== void 0 && nodeId !== void 0) {
7216
+ diagnostics.push({
7217
+ severity: "warning",
7218
+ code: "port_constraint_overlap",
7219
+ message: `Port spacing on ${nodeId} ${side} compressed from ${requestedSpacing}px to ${Math.round(effectiveSpacing)}px for ${count} ports.`,
7220
+ path: ["nodes", nodeId, "ports"],
7221
+ detail: {
7222
+ nodeId,
7223
+ side,
7224
+ requestedSpacing,
7225
+ effectiveSpacing: Math.round(effectiveSpacing),
7226
+ portCount: count
7227
+ }
7228
+ });
7229
+ }
7230
+ const spacing = effectiveSpacing;
6368
7231
  const centeredOffset = shiftingEnabled ? (index - (count - 1) / 2) * spacing : 0;
6369
7232
  switch (side) {
6370
7233
  case "left":