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]); if (currentGap >= minClearance) { // Horizontal approach lanes are well separated, but vertical // approach segments near the target may still converge (e.g., // two edges arriving at a gateway bottom face with parallel // vertical segments only 28px apart). Redirect the edge whose // horizontal approach is closest to the node center to the // upstream face (left tip for LTR). if (ElkShapeBoundaries.IsGatewayShape(targetNode) && ElkEdgeRoutingScoring.HasTargetApproachJoin( paths[0], paths[1], minClearance, 3)) { result = TryRedirectGatewayFaceOverflowEntry( result, edges, groupEdges, paths, targetNode, approachYs); } 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; } }