namespace StellaOps.ElkSharp; /// /// Ensures adequate vertical (Y-axis) clearance between nodes connected by edges. /// The Sugiyama placement uses median-based positioning that can pack connected nodes /// too tightly, leaving insufficient Y-space for clean edge routing. This pass shifts /// nodes within their layer to create routing-viable corridors. /// /// This is the Y-axis counterpart to the X-axis gutter expansion in /// . /// internal static class ElkEdgeVerticalClearance { /// /// Minimum Y-clearance between source and target nodes for edges that need /// orthogonal routing space. When nodes overlap or are too close vertically, /// edges must route through their vertical space, causing under-node violations, /// shared-lane conflicts, and short gateway hooks. /// private const double MinEdgeClearanceY = 12d; internal static bool EnforceEdgeRoutingClearance( Dictionary positionedNodes, IReadOnlyCollection edges, IReadOnlyDictionary layersByNodeId, IReadOnlyDictionary nodesById, double nodeSpacing, ElkLayoutDirection direction) { if (direction != ElkLayoutDirection.LeftToRight) { return false; } // Build layer membership for efficient within-layer adjustment. var nodesByLayer = new Dictionary>(); foreach (var (nodeId, layer) in layersByNodeId) { if (!positionedNodes.ContainsKey(nodeId)) { continue; } if (!nodesByLayer.TryGetValue(layer, out var list)) { list = []; nodesByLayer[layer] = list; } list.Add(nodeId); } // Identify edges where source and target nodes have insufficient Y-clearance. // This covers the root causes: 5px gap (edge/5), 8px gap (edge/25), 22px gap (edge/15), // and identical Y-positions (edge/32+33 sources). var adjustments = new Dictionary(StringComparer.Ordinal); foreach (var edge in edges) { if (!positionedNodes.TryGetValue(edge.SourceNodeId, out var sourceNode) || !positionedNodes.TryGetValue(edge.TargetNodeId, out var targetNode)) { continue; } // Only enforce clearance for edges crossing layers (same-layer edges // have dedicated intra-layer routing). var sourceLayer = layersByNodeId.GetValueOrDefault(edge.SourceNodeId, 0); var targetLayer = layersByNodeId.GetValueOrDefault(edge.TargetNodeId, 0); if (sourceLayer == targetLayer) { continue; } // Compute the Y-gap between the two nodes. var sourceBottom = sourceNode.Y + sourceNode.Height; var targetBottom = targetNode.Y + targetNode.Height; var yGap = sourceNode.Y >= targetBottom ? sourceNode.Y - targetBottom : targetNode.Y >= sourceBottom ? targetNode.Y - sourceBottom : 0d; // overlapping ranges → gap = 0 // Only shift when the gap is extremely tight (< MinEdgeClearanceY). // Wider gaps have adequate routing space and should not be disturbed. if (yGap >= MinEdgeClearanceY) { continue; } // Determine which node to shift. Prefer shifting the node in the later layer // to minimize cascade effects on upstream nodes. var shiftNodeId = targetLayer >= sourceLayer ? edge.TargetNodeId : edge.SourceNodeId; var anchorNode = targetLayer >= sourceLayer ? sourceNode : targetNode; var shiftNode = positionedNodes[shiftNodeId]; // Compute required shift to create MinEdgeClearanceY gap. double requiredShift; if (shiftNode.Y >= anchorNode.Y) { // Shift node is below or at same level — push it down. var currentGap = shiftNode.Y - (anchorNode.Y + anchorNode.Height); requiredShift = Math.Max(0d, MinEdgeClearanceY - currentGap); } else { // Shift node is above — push it up. var currentGap = anchorNode.Y - (shiftNode.Y + shiftNode.Height); requiredShift = -Math.Max(0d, MinEdgeClearanceY - currentGap); } if (Math.Abs(requiredShift) < 1d) { continue; } // Accumulate the largest required shift per node. var existing = adjustments.GetValueOrDefault(shiftNodeId); if (Math.Abs(requiredShift) > Math.Abs(existing)) { adjustments[shiftNodeId] = requiredShift; } } if (adjustments.Count == 0) { return false; } // Apply shifts individually — no cascade. Only shift if the node // won't overlap with its layer neighbors after the move. var applied = false; foreach (var (shiftNodeId, shift) in adjustments) { if (!positionedNodes.TryGetValue(shiftNodeId, out var node) || !layersByNodeId.TryGetValue(shiftNodeId, out var layer)) { continue; } var newY = node.Y + shift; // Check that the shifted position doesn't overlap with neighbors. if (nodesByLayer.TryGetValue(layer, out var layerNodes)) { var wouldOverlap = false; foreach (var neighborId in layerNodes) { if (string.Equals(neighborId, shiftNodeId, StringComparison.Ordinal) || !positionedNodes.TryGetValue(neighborId, out var neighbor)) { continue; } var newBottom = newY + node.Height; var neighborBottom = neighbor.Y + neighbor.Height; var gap = Math.Min( Math.Abs(newY - neighborBottom), Math.Abs(neighbor.Y - newBottom)); if (newY < neighborBottom && neighbor.Y < newBottom && gap < nodeSpacing * 0.5) { wouldOverlap = true; break; } } if (wouldOverlap) { continue; } } positionedNodes[shiftNodeId] = ElkLayoutHelpers.CreatePositionedNode( nodesById[shiftNodeId], node.X, newY, direction); applied = true; } return applied; } }