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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user