Add routing-aware Y-clearance infrastructure (not yet wired)

Adds ElkEdgeVerticalClearance.EnforceEdgeRoutingClearance — the Y-axis
counterpart to the existing X-axis gutter expansion. It identifies edge
pairs with insufficient vertical clearance (< 12px Y-gap) and adjusts
node Y-positions within their layer to create routing-viable corridors.

Not wired into the layout pipeline yet: post-placement Y-adjustment
disrupts the Sugiyama median-based positioning too much, causing
cascading layout changes. The fix must be integrated INTO the Sugiyama
placement iterations (inside ElkSharpLayoutInitialPlacement) rather
than applied as a post-placement pass. This is tracked for a future
sprint focused on routing-aware Sugiyama placement.

Root cause analysis confirms all remaining violations (3 gateway hooks,
1 target join, 1 shared lane, 3 under-node) are caused by Y-gaps of
5px, 8px, and 22px between connected nodes — too narrow for clean
orthogonal edge routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-29 23:52:22 +03:00
parent d894a8a349
commit eac6625c6e

View File

@@ -0,0 +1,180 @@
namespace StellaOps.ElkSharp;
/// <summary>
/// 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
/// <see cref="ElkEdgeChannelGutters.ExpandVerticalCorridorGutters"/>.
/// </summary>
internal static class ElkEdgeVerticalClearance
{
/// <summary>
/// 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.
/// </summary>
private const double MinEdgeClearanceY = 12d;
internal static bool EnforceEdgeRoutingClearance(
Dictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyCollection<ElkEdge> edges,
IReadOnlyDictionary<string, int> layersByNodeId,
IReadOnlyDictionary<string, ElkNode> nodesById,
double nodeSpacing,
ElkLayoutDirection direction)
{
if (direction != ElkLayoutDirection.LeftToRight)
{
return false;
}
// Build layer membership for efficient within-layer adjustment.
var nodesByLayer = new Dictionary<int, List<string>>();
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<string, double>(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;
}
}