namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
///
/// When two edges converge on the same target face with inadequate
/// Y-separation, spreads their approach lanes apart to create adequate
/// clearance. Pushes the lower edge DOWN and/or the upper edge UP by
/// the minimum amount needed to reach minClearance separation.
///
private static ElkRoutedEdge[]? ReassignConvergentTargetFace(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction)
{
if (direction != ElkLayoutDirection.LeftToRight)
{
return null;
}
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
var minClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
: 50d;
var joinSeverity = new Dictionary(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, joinSeverity, 1);
if (joinSeverity.Count < 2)
{
return null;
}
// Find pairs of edges targeting the same node with join violations.
var joinEdgeIds = joinSeverity.Keys.ToHashSet(StringComparer.Ordinal);
var edgesByTarget = edges
.Where(e => joinEdgeIds.Contains(e.Id))
.GroupBy(e => e.TargetNodeId ?? string.Empty, StringComparer.Ordinal)
.Where(g => g.Count() >= 2);
ElkRoutedEdge[]? result = null;
foreach (var group in edgesByTarget)
{
if (!nodesById.TryGetValue(group.Key, out var targetNode))
{
continue;
}
var groupEdges = group.OrderBy(e =>
{
var path = ExtractPath(e);
return path.Count >= 2 ? path[^2].Y : 0d;
}).ToArray();
if (groupEdges.Length < 2)
{
continue;
}
// Get approach Y-positions (the horizontal segment Y before the target).
var paths = groupEdges.Select(ExtractPath).ToArray();
var approachYs = new double[groupEdges.Length];
var approachSegIndices = new int[groupEdges.Length];
for (var i = 0; i < groupEdges.Length; i++)
{
// Find the last horizontal segment (the approach lane).
approachSegIndices[i] = -1;
for (var j = paths[i].Count - 2; j >= 0; j--)
{
if (Math.Abs(paths[i][j].Y - paths[i][j + 1].Y) <= 2d)
{
approachYs[i] = paths[i][j].Y;
approachSegIndices[i] = j;
break;
}
}
}
if (approachSegIndices.Any(idx => idx < 0))
{
continue;
}
// Compute the current gap and required spread.
var currentGap = Math.Abs(approachYs[1] - approachYs[0]);
// For gateway targets, try redirecting one edge to the left tip
// regardless of gap size. This is preferred over spreading because
// it shortens the path (no detour). The redirect only applies when
// HasTargetApproachJoin detects convergence.
if (ElkShapeBoundaries.IsGatewayShape(targetNode)
&& ElkEdgeRoutingScoring.HasTargetApproachJoin(
paths[0], paths[1], minClearance, 3))
{
result = TryRedirectGatewayFaceOverflowEntry(
result, edges, groupEdges, paths, targetNode, approachYs);
continue;
}
if (currentGap >= minClearance)
{
continue;
}
var spreadNeeded = minClearance - currentGap + 8d;
var halfSpread = spreadNeeded / 2d;
// Push upper edge up, lower edge down.
var upperIdx = approachYs[0] < approachYs[1] ? 0 : 1;
var lowerIdx = 1 - upperIdx;
for (var which = 0; which < 2; which++)
{
var edgeIdx = which == 0 ? upperIdx : lowerIdx;
var delta = which == 0 ? -halfSpread : halfSpread;
var segIdx = approachSegIndices[edgeIdx];
var newY = approachYs[edgeIdx] + delta;
var globalIdx = Array.FindIndex(edges, e =>
string.Equals(e.Id, groupEdges[edgeIdx].Id, StringComparison.Ordinal));
if (globalIdx < 0)
{
continue;
}
var path = paths[edgeIdx];
var newPath = BuildTargetJoinSpreadPath(path, segIdx, newY);
if (newPath.Count == 0)
{
continue;
}
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
}
ElkLayoutDiagnostics.LogProgress(
$"Target-join spread: {groupEdges[0].Id}+{groupEdges[1].Id} gap={currentGap:F0}→{currentGap + spreadNeeded:F0}px");
}
return result;
}
private static List BuildTargetJoinSpreadPath(
IReadOnlyList path,
int approachSegmentIndex,
double newY)
{
if (approachSegmentIndex < 0 || approachSegmentIndex >= path.Count - 1)
{
return [];
}
var segmentStart = path[approachSegmentIndex];
var segmentEnd = path[approachSegmentIndex + 1];
var segmentLength = Math.Abs(segmentEnd.X - segmentStart.X);
if (segmentLength <= 4d)
{
return [];
}
var inset = Math.Clamp(segmentLength / 4d, 12d, 24d);
var transitionX = segmentEnd.X >= segmentStart.X
? Math.Max(segmentStart.X + 4d, segmentEnd.X - inset)
: Math.Min(segmentStart.X - 4d, segmentEnd.X + inset);
if (Math.Abs(transitionX - segmentStart.X) <= 2d
|| Math.Abs(segmentEnd.X - transitionX) <= 2d)
{
transitionX = (segmentStart.X + segmentEnd.X) / 2d;
}
var newPath = new List(path.Count + 2);
for (var i = 0; i < approachSegmentIndex; i++)
{
AddUnique(newPath, path[i]);
}
AddUnique(newPath, segmentStart);
AddUnique(newPath, new ElkPoint { X = transitionX, Y = segmentStart.Y });
AddUnique(newPath, new ElkPoint { X = transitionX, Y = newY });
AddUnique(newPath, new ElkPoint { X = segmentEnd.X, Y = newY });
for (var i = approachSegmentIndex + 2; i < path.Count; i++)
{
AddUnique(newPath, path[i]);
}
return newPath;
}
private static void AddUnique(List points, ElkPoint point)
{
if (points.Count > 0 && ElkEdgeRoutingGeometry.PointsEqual(points[^1], point))
{
return;
}
points.Add(new ElkPoint { X = point.X, Y = point.Y });
}
///
/// When two edges converge on a gateway face with insufficient room for
/// proper slot spacing, redirects the edge whose horizontal approach is
/// closest to the node center Y to the left tip vertex (for LTR layout).
/// This handles the case where horizontal approach Y gaps are large but
/// vertical approach segments near the target are too close in X.
///
private static ElkRoutedEdge[]? TryRedirectGatewayFaceOverflowEntry(
ElkRoutedEdge[]? result,
ElkRoutedEdge[] edges,
ElkRoutedEdge[] groupEdges,
IReadOnlyList[] paths,
ElkPositionedNode targetNode,
double[] approachYs)
{
if (groupEdges.Length < 2)
{
return result;
}
var centerY = targetNode.Y + targetNode.Height / 2d;
// Pick the edge whose horizontal approach Y is closest to the node
// center -- that edge naturally wants to enter from the upstream face
// (left for LTR) rather than the bottom/top face.
var redirectIdx = -1;
var bestDistToCenter = double.MaxValue;
for (var i = 0; i < groupEdges.Length; i++)
{
var dist = Math.Abs(approachYs[i] - centerY);
if (dist < bestDistToCenter)
{
bestDistToCenter = dist;
redirectIdx = i;
}
}
if (redirectIdx < 0)
{
return result;
}
var redirectPath = paths[redirectIdx];
var leftTipX = targetNode.X;
var leftTipY = centerY;
// Find the last path point that is clearly outside the target node's
// left boundary. Keep all path segments up to that point and build a
// clean entry through the left tip.
var lastOutsideIdx = -1;
for (var j = redirectPath.Count - 1; j >= 0; j--)
{
if (redirectPath[j].X < leftTipX - 4d)
{
lastOutsideIdx = j;
break;
}
}
if (lastOutsideIdx < 0)
{
return result;
}
// Build the redirected path: keep everything up to the last outside
// point, then route horizontally to a stub 24px left of the tip,
// bend vertically to the tip Y, and enter at the tip. The stub X
// must be near the target (not the source) to preserve the source
// exit angle as a clean horizontal departure.
var outsidePoint = redirectPath[lastOutsideIdx];
var stubX = leftTipX - 24d;
var newPath = new List(lastOutsideIdx + 5);
for (var j = 0; j <= lastOutsideIdx; j++)
{
AddUnique(newPath, redirectPath[j]);
}
// Horizontal approach to the stub X (preserves source exit angle).
if (Math.Abs(outsidePoint.X - stubX) > 2d)
{
AddUnique(newPath, new ElkPoint { X = stubX, Y = outsidePoint.Y });
}
// Vertical transition to the tip Y.
var currentY = newPath.Count > 0 ? newPath[^1].Y : outsidePoint.Y;
if (Math.Abs(currentY - leftTipY) > 2d)
{
AddUnique(newPath, new ElkPoint { X = stubX, Y = leftTipY });
}
// Enter at the left tip vertex.
AddUnique(newPath, new ElkPoint { X = leftTipX, Y = leftTipY });
var globalIdx = Array.FindIndex(edges, e =>
string.Equals(e.Id, groupEdges[redirectIdx].Id, StringComparison.Ordinal));
if (globalIdx < 0)
{
return result;
}
result = ReplaceEdgePath(result, edges, globalIdx, edges[globalIdx], newPath);
ElkLayoutDiagnostics.LogProgress(
$"Gateway face redirect: {groupEdges[redirectIdx].Id} to left tip of {targetNode.Id}");
return result;
}
}