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>
181 lines
6.9 KiB
C#
181 lines
6.9 KiB
C#
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;
|
|
}
|
|
}
|