diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs index 41243d947..d35f091db 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs @@ -188,7 +188,7 @@ public sealed class WorkflowRenderSvgRenderer ], trunkStyle, collectorStrokeWidth, - highway.IsBackward ? null : trunkStyle.MarkerId, + trunkStyle.MarkerId, collectorOpacity, highway.GroupId, IsCollector: true)); @@ -251,7 +251,29 @@ public sealed class WorkflowRenderSvgRenderer } var renderedPath = renderedPaths[pathIndex]; - var pathData = BuildRoundedEdgePath(renderedPath.Points, offsetX, offsetY, renderedPath.IsCollector ? 0d : 12d); + var renderPoints = renderedPath.Points; + if (!string.IsNullOrWhiteSpace(renderedPath.MarkerId) && renderPoints.Count >= 2) + { + var arrowLen = 5d * renderedPath.StrokeWidth; + var last = renderPoints[^1]; + var prev = renderPoints[^2]; + var dx = last.X - prev.X; + var dy = last.Y - prev.Y; + var segLen = Math.Sqrt(dx * dx + dy * dy); + if (segLen > arrowLen * 0.5d) + { + var pullback = Math.Min(arrowLen * 0.7d, segLen * 0.6d); + var shortened = renderPoints.ToList(); + shortened[^1] = new WorkflowRenderPoint + { + X = last.X - (dx / segLen * pullback), + Y = last.Y - (dy / segLen * pullback), + }; + renderPoints = shortened; + } + } + + var pathData = BuildRoundedEdgePath(renderPoints, offsetX, offsetY, renderedPath.IsCollector ? 0d : 12d); var markerAttribute = string.IsNullOrWhiteSpace(renderedPath.MarkerId) ? string.Empty : $" marker-end=\"{renderedPath.MarkerId}\""; @@ -491,7 +513,7 @@ public sealed class WorkflowRenderSvgRenderer {Encode(placement.Label)} """); @@ -869,10 +891,11 @@ public sealed class WorkflowRenderSvgRenderer double anchorY; if (isErrorLabel && points.Count >= 2) { - var sourcePoint = points[0]; - var secondPoint = points[Math.Min(1, points.Count - 1)]; - anchorX = (sourcePoint.X * 0.6d + secondPoint.X * 0.4d) + offsetX; - anchorY = (sourcePoint.Y * 0.6d + secondPoint.Y * 0.4d) + offsetY; + var longestSeg = ResolveLabelAnchorSegment(points); + var segMidX = (longestSeg.Start.X + longestSeg.End.X) / 2d; + var segMidY = (longestSeg.Start.Y + longestSeg.End.Y) / 2d; + anchorX = segMidX + offsetX; + anchorY = segMidY + offsetY; } else { @@ -1256,9 +1279,10 @@ public sealed class WorkflowRenderSvgRenderer group.First().FamilyKey, isBackward); }) - .Where(candidate => candidate.IsBackward || !string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase)) + .Where(candidate => candidate.IsBackward + || string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase)) .Where(candidate => - candidate.EdgeIds.Count >= (string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) ? 2 : 3)) + candidate.EdgeIds.Count >= (candidate.IsBackward ? 3 : 2)) .ToArray(); foreach (var targetDirectionGroup in candidateGroups @@ -1291,20 +1315,45 @@ public sealed class WorkflowRenderSvgRenderer .FirstOrDefault(); collectorY = preferredCollectorY ?? (targetNode.Y - 42d - ((orderedGroups.Length - 1) * 4d)); - var requiredOverlapCount = candidate.EdgeIds.Count <= 2 ? candidate.EdgeIds.Count : (candidate.EdgeIds.Count / 2) + 1; - if (!TryResolveHorizontalOverlapInterval(candidate.Edges, collectorY, requiredOverlapCount, out var sharedMinX, out var sharedMaxX)) + + // Ensure collectorY doesn't pass through any node + var collectorMinX = candidate.Edges.Min(edge => edge.Sections.First().EndPoint.X); + var collectorMaxX = candidate.Edges.Max(edge => edge.Sections.First().StartPoint.X); + foreach (var obstacleNode in layout.Nodes) { - collectorX = targetNode.X + (targetNode.Width / 2d) - + ResolveCenteredOffset(bandIndex, orderedGroups.Length, Math.Min(18d, targetNode.Width / 4d)); - targetX = collectorX; + if (string.Equals(obstacleNode.Id, candidate.TargetId, StringComparison.Ordinal)) + { + continue; + } + + if (obstacleNode.X + obstacleNode.Width > collectorMinX + && obstacleNode.X < collectorMaxX + && collectorY > obstacleNode.Y - 24d + && collectorY < obstacleNode.Y + obstacleNode.Height + 24d) + { + collectorY = obstacleNode.Y - 36d; + } + } + + var requiredOverlapCount = candidate.EdgeIds.Count <= 2 ? candidate.EdgeIds.Count : (candidate.EdgeIds.Count / 2) + 1; + if (!TryResolveHorizontalOverlapInterval(candidate.Edges, collectorY, requiredOverlapCount, out var sharedMinX, out var sharedMaxX) + || Math.Abs(sharedMaxX - sharedMinX) < 40d) + { + collectorX = targetNode.X + (targetNode.Width / 2d) + 24d; + targetX = targetNode.X + (targetNode.Width / 2d); } else { collectorX = sharedMaxX; - targetX = sharedMinX; + targetX = Math.Min(sharedMinX, targetNode.X + (targetNode.Width / 2d)); } targetY = targetNode.Y; + if (Math.Abs(collectorY - targetY) < 28d) + { + collectorY = targetY - 32d; + } + spreadPerEdge = 0d; groups[groupId] = new HighwayGroup( diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs similarity index 80% rename from src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs rename to src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs index 0bcc75695..c209fba07 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/AssistantPrintInsisDocumentsRenderingTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs @@ -8,39 +8,39 @@ using StellaOps.Workflow.Renderer.Svg; namespace StellaOps.Workflow.Renderer.Tests; [TestFixture] -public class AssistantPrintInsisDocumentsRenderingTests +public class DocumentProcessingWorkflowRenderingTests { - private static WorkflowRenderGraph BuildAssistantPrintInsisDocumentsGraph() + private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph() { return new WorkflowRenderGraph { - Id = "AssistantPrintInsisDocuments:1.0.0", + Id = "DocumentProcessingWorkflow:1.0.0", Nodes = [ new WorkflowRenderNode { Id = "start", Label = "Start", Kind = "Start", Width = 264, Height = 132 }, - new WorkflowRenderNode { Id = "start/1", Label = "Assign Business Reference", Kind = "BusinessReference", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/2/split", Label = "Spin off async process", Kind = "Fork", Width = 176, Height = 124 }, - new WorkflowRenderNode { Id = "start/2/join", Label = "Spin off async process Join", Kind = "Join", Width = 176, Height = 124 }, - new WorkflowRenderNode { Id = "start/3", Label = "Load Notification Parameters", Kind = "TransportCall", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/4/batched", Label = "Setting:\nnotificationParameters\nskipSystemNotification\ntoEmailsCount\nnotificationHasBody\nnotificationHasTitle", Kind = "SetState", Width = 224, Height = 104 }, - new WorkflowRenderNode { Id = "start/9", Label = "Has Notification Content", Kind = "Decision", Width = 188, Height = 132 }, - new WorkflowRenderNode { Id = "start/9/true/1", Label = "Send Private Note", Kind = "Decision", Width = 188, Height = 132 }, - new WorkflowRenderNode { Id = "start/9/true/1/true/1", Label = "Send Private Note", Kind = "TransportCall", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/9/true/1/true/1/handled/1", Label = "Set notificationPrivateNoteFailed", Kind = "SetState", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/9/true/2", Label = "Has Notification Emails", Kind = "Decision", Width = 188, Height = 132 }, - new WorkflowRenderNode { Id = "start/9/true/2/true/1", Label = "Send Notification Email", Kind = "TransportCall", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/9/true/2/true/1/handled/1", Label = "Set notificationEmailFailed", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/1", Label = "Initialize Context", Kind = "BusinessReference", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/split", Label = "Parallel Execution", Kind = "Fork", Width = 176, Height = 124 }, + new WorkflowRenderNode { Id = "start/2/join", Label = "Parallel Execution Join", Kind = "Join", Width = 176, Height = 124 }, + new WorkflowRenderNode { Id = "start/3", Label = "Load Configuration", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/4/batched", Label = "Setting:\nconfigParameters\nskipInternalNotification\nrecipientCount\nconfigHasBody\nconfigHasTitle", Kind = "SetState", Width = 224, Height = 104 }, + new WorkflowRenderNode { Id = "start/9", Label = "Evaluate Conditions", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/9/true/1", Label = "Internal Notification", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/9/true/1/true/1", Label = "Internal Notification", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/9/true/1/true/1/handled/1", Label = "Set internalNotificationFailed", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/9/true/2", Label = "Has Recipients", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/9/true/2/true/1", Label = "Email Dispatch", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/9/true/2/true/1/handled/1", Label = "Set emailDispatchFailed", Kind = "SetState", Width = 208, Height = 88 }, new WorkflowRenderNode { Id = "end", Label = "End", Kind = "End", Width = 264, Height = 132 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1", Label = "Generate Documents", Kind = "Repeat", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/1/batched", Label = "Setting:\nprintTimedOut\nprintGenerateFailed\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4", Label = "Print Batch Documents", Kind = "TransportCall", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1", Label = "Attempt Again", Kind = "Decision", Width = 188, Height = 132 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "Wait 5m", Kind = "Timer", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/2", Label = "Set printGenerateFailed", Kind = "SetState", Width = 208, Height = 88 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5", Label = "Print Batch Returned Result", Kind = "Decision", Width = 188, Height = 132 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1", Label = "Print Batch Succeeded", Kind = "Decision", Width = 188, Height = 132 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "Setting:\npolicyNo\nfiles\ndocsCount\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 }, - new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set printTimedOut", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1", Label = "Process Batch", Kind = "Repeat", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/1/batched", Label = "Setting:\nbatchTimedOut\nbatchGenerateFailed\nhasMissingItems", Kind = "SetState", Width = 224, Height = 104 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4", Label = "Execute Batch", Kind = "TransportCall", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1", Label = "Retry Decision", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "Cooldown Timer", Kind = "Timer", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/2", Label = "Set batchGenerateFailed", Kind = "SetState", Width = 208, Height = 88 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5", Label = "Check Result", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1", Label = "Validate Success", Kind = "Decision", Width = 188, Height = 132 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "Setting:\nitemId\nfiles\nitemsCount\nhasMissingItems", Kind = "SetState", Width = 224, Height = 104 }, + new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set batchTimedOut", Kind = "SetState", Width = 208, Height = 88 }, ], Edges = [ @@ -85,9 +85,9 @@ public class AssistantPrintInsisDocumentsRenderingTests } [Test] - public async Task AssistantPrintInsisDocuments_WhenLayoutOnly_ShouldProduceFinitePositions() + public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions() { - var graph = BuildAssistantPrintInsisDocumentsGraph(); + var graph = BuildDocumentProcessingWorkflowGraph(); var engine = new ElkSharpWorkflowRenderLayoutEngine(); var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest @@ -102,9 +102,9 @@ public class AssistantPrintInsisDocumentsRenderingTests [Test] [Category("RenderingArtifacts")] - public async Task AssistantPrintInsisDocuments_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings() + public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings() { - var graph = BuildAssistantPrintInsisDocumentsGraph(); + var graph = BuildDocumentProcessingWorkflowGraph(); var engine = new ElkSharpWorkflowRenderLayoutEngine(); var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest @@ -113,11 +113,11 @@ public class AssistantPrintInsisDocumentsRenderingTests }); var svgRenderer = new WorkflowRenderSvgRenderer(); - var svgDoc = svgRenderer.Render(layout, "AssistantPrintInsisDocuments [ElkSharp]"); + var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]"); var outputDir = Path.Combine( - Path.GetDirectoryName(typeof(AssistantPrintInsisDocumentsRenderingTests).Assembly.Location)!, - "TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "AssistantPrintInsisDocuments"); + Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!, + "TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow"); Directory.CreateDirectory(outputDir); var svgPath = Path.Combine(outputDir, "elksharp.svg"); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelBands.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelBands.cs new file mode 100644 index 000000000..14e8222c5 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelBands.cs @@ -0,0 +1,201 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeChannelBands +{ + internal static void AllocateDirectForwardChannelBands( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layerBoundariesByNodeId, + Dictionary channels, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return; + } + + var candidates = edges + .Select(edge => TryCreateDirectChannelCandidate(edge, positionedNodes, layerBoundariesByNodeId, channels)) + .Where(candidate => candidate is not null) + .Select(candidate => candidate!.Value) + .GroupBy(candidate => candidate.GapKey, StringComparer.Ordinal); + + foreach (var gapGroup in candidates) + { + var ordered = gapGroup + .OrderBy(candidate => candidate.TargetCenterY) + .ThenBy(candidate => candidate.TargetX) + .ThenBy(candidate => candidate.SourceCenterY) + .ThenBy(candidate => candidate.FamilyPriority) + .ThenBy(candidate => candidate.EdgeId, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length == 0) + { + continue; + } + + var gapMinX = ordered.Max(candidate => candidate.GapMinX); + var gapMaxX = ordered.Min(candidate => candidate.GapMaxX); + if (gapMaxX - gapMinX < 24d) + { + continue; + } + + var edgePadding = Math.Min(28d, Math.Max(16d, (gapMaxX - gapMinX) * 0.12d)); + var usableMinX = gapMinX + edgePadding; + var usableMaxX = gapMaxX - edgePadding; + if (usableMaxX <= usableMinX) + { + usableMinX = gapMinX + 12d; + usableMaxX = gapMaxX - 12d; + } + + for (var index = 0; index < ordered.Length; index++) + { + var preferredX = ordered.Length == 1 + ? (usableMinX + usableMaxX) / 2d + : usableMinX + ((usableMaxX - usableMinX) * (index / (double)(ordered.Length - 1))); + preferredX = ElkLayoutHelpers.Clamp(preferredX, ordered[index].GapMinX + 8d, ordered[index].GapMaxX - 8d); + + if (!channels.TryGetValue(ordered[index].EdgeId, out var channel)) + { + continue; + } + + channels[ordered[index].EdgeId] = channel with + { + PreferredDirectChannelX = preferredX, + }; + } + } + } + + internal static DirectChannelCandidate? TryCreateDirectChannelCandidate( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layerBoundariesByNodeId, + IReadOnlyDictionary channels) + { + if (!channels.TryGetValue(edge.Id, out var channel) || channel.RouteMode != EdgeRouteMode.Direct) + { + return null; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var sourceCenterX = source.X + (source.Width / 2d); + var targetCenterX = target.X + (target.Width / 2d); + if (targetCenterX <= sourceCenterX + 1d) + { + return null; + } + + var sourceCenterY = source.Y + (source.Height / 2d); + var targetCenterY = target.Y + (target.Height / 2d); + if (Math.Abs(targetCenterY - sourceCenterY) < 56d) + { + return null; + } + + var sourceBoundary = ElkLayoutHelpers.ResolveLayerBoundary(edge.SourceNodeId, layerBoundariesByNodeId, source); + var targetBoundary = ElkLayoutHelpers.ResolveLayerBoundary(edge.TargetNodeId, layerBoundariesByNodeId, target); + var gapMinX = sourceBoundary.MaxX + 12d; + var gapMaxX = targetBoundary.MinX - 12d; + if (gapMaxX - gapMinX < 48d) + { + return null; + } + + var gapKey = $"{Math.Round(gapMinX, 2):0.##}|{Math.Round(gapMaxX, 2):0.##}"; + return new DirectChannelCandidate( + edge.Id, + gapKey, + gapMinX, + gapMaxX, + ResolveLaneFamilyPriority(edge.Label), + sourceCenterY, + targetCenterY, + target.X); + } + + internal static string ResolveLaneFamilyKey(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return "default"; + } + + var normalized = label.Trim().ToLowerInvariant(); + if (normalized.Contains("failure", StringComparison.Ordinal)) + { + return "failure"; + } + + if (normalized.Contains("timeout", StringComparison.Ordinal)) + { + return "timeout"; + } + + if (normalized.StartsWith("repeat ", StringComparison.Ordinal) + || normalized.Equals("body", StringComparison.Ordinal)) + { + return "repeat"; + } + + if (normalized.StartsWith("when ", StringComparison.Ordinal)) + { + return "success"; + } + + if (normalized.Contains("otherwise", StringComparison.Ordinal) + || normalized.Contains("default", StringComparison.Ordinal)) + { + return "default"; + } + + if (normalized.Contains("missing condition", StringComparison.Ordinal)) + { + return "missing-condition"; + } + + return "default"; + } + + internal static int ResolveLaneFamilyPriority(string? label) + { + return ResolveLaneFamilyKey(label) switch + { + "failure" => 0, + "timeout" => 1, + "repeat" => 2, + "default" => 3, + "success" => 4, + "missing-condition" => 5, + _ => 6, + }; + } + + internal static int ResolveSinkLanePriority(string? label) + { + return ResolveLaneFamilyKey(label) switch + { + "default" => 0, + "success" => 1, + "repeat" => 2, + "timeout" => 3, + "failure" => 4, + "missing-condition" => 5, + _ => 6, + }; + } + + internal static double ResolveSinkBandOffset(int bandIndex, double firstSpacing = 36d, double subsequentSpacing = 28d) + { + if (bandIndex <= 0) + { + return 0d; + } + + return firstSpacing + ((bandIndex - 1) * subsequentSpacing); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelCorridors.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelCorridors.cs new file mode 100644 index 000000000..38293a5cf --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelCorridors.cs @@ -0,0 +1,236 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeChannelCorridors +{ + internal static readonly double[] CorridorSampleFractions = [0.2d, 0.35d, 0.5d, 0.65d, 0.8d]; + + internal static double ResolveBackwardCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var endpointTop = double.PositiveInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + endpointTop = Math.Min(endpointTop, Math.Min(source.Y, target.Y)); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + var maxAllowed = endpointTop - 24d; + if (maxAllowed <= merged[0].Top) + { + return double.NaN; + } + + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = merged[index].Bottom; + var freeBottom = Math.Min(maxAllowed, merged[index + 1].Top); + var gapHeight = freeBottom - freeTop; + if (gapHeight < 56d) + { + continue; + } + + var candidateMin = freeTop + 18d; + var candidateMax = freeBottom - 12d; + if (candidateMax <= candidateMin) + { + continue; + } + + var desiredY = Math.Min(maxAllowed - 8d, freeTop + (gapHeight * 0.72d)); + foreach (var fraction in CorridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + [], + false, + freeTop); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + internal static double ResolveBackwardLowerCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var minAllowed = double.NegativeInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + minAllowed = Math.Max(minAllowed, Math.Max(source.Y + source.Height, target.Y + target.Height) + 14d); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = Math.Max(minAllowed, merged[index].Bottom); + var freeBottom = merged[index + 1].Top; + var gapHeight = freeBottom - freeTop; + if (gapHeight < 44d) + { + continue; + } + + var candidateMin = freeTop + 12d; + var candidateMax = freeBottom - 12d; + if (candidateMax <= candidateMin) + { + continue; + } + + var desiredY = freeTop + Math.Min(40d, gapHeight * 0.32d); + foreach (var fraction in CorridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + [], + true, + minAllowed) + + 28d; + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + internal static double ScoreHorizontalCorridorCandidate( + IReadOnlyCollection spanNodes, + double freeTop, + double freeBottom, + double candidate, + double desiredY, + IReadOnlyCollection reservedBands, + bool rewardInterior, + double minAllowed) + { + var gapHeight = freeBottom - freeTop; + var clearance = Math.Min(candidate - freeTop, freeBottom - candidate); + var bandSeparation = reservedBands.Count == 0 + ? 144d + : reservedBands.Min(band => Math.Abs(candidate - band)); + var rowPenalty = spanNodes.Sum(node => + { + var nodeTop = node.Y - 6d; + var nodeBottom = node.Y + node.Height + 6d; + if (candidate >= nodeTop && candidate <= nodeBottom) + { + return 100000d; + } + + var centerY = node.Y + (node.Height / 2d); + var distance = Math.Abs(centerY - candidate); + return distance >= 96d ? 0d : (96d - distance) * 0.32d; + }); + + return gapHeight + + (clearance * 1.8d) + + (Math.Min(120d, bandSeparation) * 0.7d) + - (Math.Abs(candidate - desiredY) * 0.35d) + - rowPenalty + + (rewardInterior && freeTop >= minAllowed + 64d ? 22d : 0d); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.cs new file mode 100644 index 000000000..fc3319586 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.cs @@ -0,0 +1,235 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeChannelGutters +{ + internal static bool ExpandVerticalCorridorGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double baseLayerSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .OrderBy(group => group.Key) + .Select(group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new + { + Layer = group.Key, + Boundary = new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)), + }; + }) + .ToArray(); + if (boundariesByLayer.Length < 2) + { + return false; + } + + var requiredBoundaryDeltas = new Dictionary(); + for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) + { + var current = boundariesByLayer[boundaryIndex]; + var next = boundariesByLayer[boundaryIndex + 1]; + var gap = next.Boundary.MinX - current.Boundary.MaxX; + if (gap <= 0d) + { + continue; + } + + var verticalSegments = routedEdges + .SelectMany(edge => edge.Sections.SelectMany(section => + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points.Zip(points.Skip(1), (start, end) => new + { + Edge = edge, + Start = start, + End = end, + }); + })) + .Where(segment => + Math.Abs(segment.Start.X - segment.End.X) <= 0.01d + && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d + && segment.Start.X > current.Boundary.MaxX + 8d + && segment.Start.X < next.Boundary.MinX - 8d) + .ToArray(); + var laneCount = verticalSegments + .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) + .Distinct() + .Count(); + if (laneCount == 0) + { + continue; + } + + var familyCount = verticalSegments + .Select(segment => ElkEdgeChannelBands.ResolveLaneFamilyKey(segment.Edge.Label)) + .Distinct(StringComparer.Ordinal) + .Count(); + var desiredGap = Math.Max( + baseLayerSpacing + 88d, + 136d + (laneCount * 28d) + (Math.Max(0, familyCount - 1) * 24d)); + if (gap >= desiredGap) + { + continue; + } + + requiredBoundaryDeltas[current.Layer] = desiredGap - gap; + } + + if (requiredBoundaryDeltas.Count == 0) + { + return false; + } + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) + { + continue; + } + + var shiftX = requiredBoundaryDeltas + .Where(entry => nodeLayer > entry.Key) + .Sum(entry => entry.Value); + if (shiftX <= 0.01d) + { + continue; + } + + var current = positionedNodes[nodeId]; + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); + } + + return true; + } + + internal static bool CompactSparseVerticalCorridorGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double baseLayerSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .OrderBy(group => group.Key) + .Select(group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new + { + Layer = group.Key, + Boundary = new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)), + }; + }) + .ToArray(); + if (boundariesByLayer.Length < 2) + { + return false; + } + + var boundaryShifts = new Dictionary(); + for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) + { + var current = boundariesByLayer[boundaryIndex]; + var next = boundariesByLayer[boundaryIndex + 1]; + var gap = next.Boundary.MinX - current.Boundary.MaxX; + if (gap <= 0d) + { + continue; + } + + var verticalSegments = routedEdges + .SelectMany(edge => edge.Sections.SelectMany(section => + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points.Zip(points.Skip(1), (start, end) => new + { + Edge = edge, + Start = start, + End = end, + }); + })) + .Where(segment => + Math.Abs(segment.Start.X - segment.End.X) <= 0.01d + && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d + && segment.Start.X > current.Boundary.MaxX + 8d + && segment.Start.X < next.Boundary.MinX - 8d) + .ToArray(); + var laneCount = verticalSegments + .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) + .Distinct() + .Count(); + var familyCount = verticalSegments + .Select(segment => ElkEdgeChannelBands.ResolveLaneFamilyKey(segment.Edge.Label)) + .Distinct(StringComparer.Ordinal) + .Count(); + + var desiredGap = Math.Max( + baseLayerSpacing * 0.72d, + 120d + (laneCount * 20d) + (Math.Max(0, familyCount - 1) * 16d)); + var maxGap = desiredGap + 28d; + if (gap <= maxGap) + { + continue; + } + + boundaryShifts[current.Layer] = desiredGap - gap; + } + + if (boundaryShifts.Count == 0) + { + return false; + } + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) + { + continue; + } + + var shiftX = boundaryShifts + .Where(entry => nodeLayer > entry.Key) + .Sum(entry => entry.Value); + if (Math.Abs(shiftX) <= 0.01d) + { + continue; + } + + var current = positionedNodes[nodeId]; + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); + } + + return true; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelSinkCorridors.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelSinkCorridors.cs new file mode 100644 index 000000000..95bcdff7b --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelSinkCorridors.cs @@ -0,0 +1,100 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeChannelSinkCorridors +{ + internal static double ResolveSinkCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + IReadOnlyCollection reservedBands) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var minAllowed = double.NegativeInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + minAllowed = Math.Max(minAllowed, source.Y + source.Height + 18d); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + var occupiedBottom = merged[^1].Bottom; + var desiredY = minAllowed + Math.Min(280d, Math.Max(0d, occupiedBottom - minAllowed) * 0.35d); + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = Math.Max(minAllowed, merged[index].Bottom); + var freeBottom = merged[index + 1].Top; + var gapHeight = freeBottom - freeTop; + if (gapHeight < 56d) + { + continue; + } + + var candidateMin = freeTop + 20d; + var candidateMax = freeBottom - 20d; + if (candidateMax <= candidateMin) + { + continue; + } + + foreach (var fraction in ElkEdgeChannelCorridors.CorridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ElkEdgeChannelCorridors.ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + reservedBands, + true, + minAllowed); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs new file mode 100644 index 000000000..f5731ccb8 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs @@ -0,0 +1,246 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeChannels +{ + internal static Dictionary ComputeEdgeChannels( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var channels = new Dictionary(edges.Count, StringComparer.Ordinal); + var backwardEdges = new List(); + var forwardEdgesBySource = new Dictionary>(StringComparer.Ordinal); + var outgoingCounts = edges + .GroupBy(edge => edge.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var isBackward = direction == ElkLayoutDirection.LeftToRight + ? (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d)) + : (target.Y + (target.Height / 2d)) < (source.Y + (source.Height / 2d)); + + if (isBackward) + { + backwardEdges.Add(edge); + } + else + { + if (!forwardEdgesBySource.TryGetValue(edge.SourceNodeId, out var list)) + { + list = []; + forwardEdgesBySource[edge.SourceNodeId] = list; + } + + list.Add(edge); + } + } + + var backwardGroups = backwardEdges + .GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal) + .SelectMany(targetGroup => + { + var families = targetGroup + .GroupBy(edge => ElkEdgeChannelBands.ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) + .OrderBy(group => ElkEdgeChannelBands.ResolveLaneFamilyPriority(group.First().Label)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + return families.Select((familyGroup, familyIndex) => new + { + TargetNodeId = targetGroup.Key, + FamilyKey = familyGroup.Key, + FamilyIndex = familyIndex, + FamilyCount = families.Length, + Edges = familyGroup.ToArray(), + SharedOuterX = familyGroup.Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + return s.X + s.Width; + }) + 56d, + Priority = ElkEdgeChannelBands.ResolveLaneFamilyPriority(familyGroup.First().Label), + Span = familyGroup.Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + var t = positionedNodes[edge.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? Math.Abs((s.X + (s.Width / 2d)) - (t.X + (t.Width / 2d))) + : Math.Abs((s.Y + (s.Height / 2d)) - (t.Y + (t.Height / 2d))); + }), + }); + }) + .OrderByDescending(group => group.Span) + .ThenBy(group => group.Priority) + .ThenBy(group => group.TargetNodeId, StringComparer.Ordinal) + .ToArray(); + + var reservedHorizontalBands = new List(); + for (var laneIndex = 0; laneIndex < backwardGroups.Length; laneIndex++) + { + var group = backwardGroups[laneIndex]; + var useSourceCollector = string.Equals(group.FamilyKey, "repeat", StringComparison.Ordinal); + var preferredOuterY = double.NaN; + if (direction == ElkLayoutDirection.LeftToRight && useSourceCollector) + { + var lowerCorridorY = ElkEdgeChannelCorridors.ResolveBackwardLowerCorridorY(group.Edges, positionedNodes); + preferredOuterY = !double.IsNaN(lowerCorridorY) + ? lowerCorridorY + : ElkEdgeChannelCorridors.ResolveBackwardCorridorY(group.Edges, positionedNodes); + } + + if (!double.IsNaN(preferredOuterY)) + { + reservedHorizontalBands.Add(preferredOuterY); + } + + foreach (var edge in group.Edges) + { + channels[edge.Id] = new EdgeChannel( + EdgeRouteMode.BackwardOuter, + laneIndex, + group.FamilyIndex, + group.FamilyCount, + 0, + 1, + 0, + 1, + -1, + 0, + group.SharedOuterX, + preferredOuterY, + useSourceCollector, + double.NaN); + } + } + + var forwardEdgesByTarget = new Dictionary>(StringComparer.Ordinal); + foreach (var sourceEdges in forwardEdgesBySource.Values) + { + foreach (var edge in sourceEdges) + { + if (!forwardEdgesByTarget.TryGetValue(edge.TargetNodeId, out var list)) + { + list = []; + forwardEdgesByTarget[edge.TargetNodeId] = list; + } + + list.Add(edge); + } + } + + var sinkBandsByEdgeId = new Dictionary(StringComparer.Ordinal); + if (direction == ElkLayoutDirection.LeftToRight) + { + var reservedSinkBands = new List(reservedHorizontalBands); + foreach (var targetEdges in forwardEdgesByTarget) + { + var targetNode = positionedNodes[targetEdges.Key]; + var isSinkTarget = string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + || !outgoingCounts.ContainsKey(targetEdges.Key); + if (!isSinkTarget || targetEdges.Value.Count < 2) + { + continue; + } + + var sinkBands = targetEdges.Value + .GroupBy(edge => ElkEdgeChannelBands.ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) + .OrderBy(group => ElkEdgeChannelBands.ResolveSinkLanePriority(group.First().Label)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + for (var bandIndex = 0; bandIndex < sinkBands.Length; bandIndex++) + { + var sinkBandEdges = sinkBands[bandIndex].ToArray(); + var sharedOuterX = sinkBands[bandIndex].Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + return s.X + s.Width; + }) + 56d; + var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sinkBands[bandIndex].First().Label); + var preferredOuterY = familyKey is "failure" or "timeout" + ? ElkEdgeChannelSinkCorridors.ResolveSinkCorridorY(sinkBandEdges, positionedNodes, reservedSinkBands) + : double.NaN; + if (!double.IsNaN(preferredOuterY)) + { + reservedSinkBands.Add(preferredOuterY + (bandIndex * 24d)); + } + + foreach (var edge in sinkBandEdges) + { + sinkBandsByEdgeId[edge.Id] = (bandIndex, sinkBands.Length, sharedOuterX, preferredOuterY); + } + } + } + } + + foreach (var sourceEdges in forwardEdgesBySource.Values) + { + var sorted = sourceEdges + .OrderBy(e => + { + var t = positionedNodes[e.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? t.Y + (t.Height / 2d) + : t.X + (t.Width / 2d); + }) + .ToArray(); + + for (var index = 0; index < sorted.Length; index++) + { + var targetEdges = forwardEdgesByTarget.GetValueOrDefault(sorted[index].TargetNodeId); + var targetIncomingIndex = 0; + var targetIncomingCount = 1; + if (targetEdges is not null && targetEdges.Count > 1) + { + var sortedBySourceY = targetEdges + .OrderBy(e => + { + var s = positionedNodes[e.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? s.Y + (s.Height / 2d) + : s.X + (s.Width / 2d); + }) + .ToList(); + targetIncomingIndex = sortedBySourceY.FindIndex(e => string.Equals(e.Id, sorted[index].Id, StringComparison.Ordinal)); + targetIncomingCount = sortedBySourceY.Count; + } + + var sinkBand = sinkBandsByEdgeId.GetValueOrDefault(sorted[index].Id, (-1, 0, 0d, double.NaN)); + var routeMode = EdgeRouteMode.Direct; + if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) + { + var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label); + if (familyKey is "failure" or "timeout") + { + routeMode = EdgeRouteMode.SinkOuterTop; + } + else + { + routeMode = EdgeRouteMode.SinkOuter; + } + } + + channels[sorted[index].Id] = new EdgeChannel( + routeMode, + -1, + 0, + 1, + index, + sorted.Length, + targetIncomingIndex, + targetIncomingCount, + sinkBand.Item1, + sinkBand.Item2, + sinkBand.Item3, + sinkBand.Item4, + false, + double.NaN); + } + } + + ElkEdgeChannelBands.AllocateDirectForwardChannelBands(edges, positionedNodes, layerBoundariesByNodeId, channels, direction); + + return channels; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs new file mode 100644 index 000000000..d505761b1 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs @@ -0,0 +1,295 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgePostProcessor +{ + internal static ElkRoutedEdge[] SnapAnchorsToNodeBoundary( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var anyChanged = false; + var newSections = edge.Sections.ToList(); + + for (var s = 0; s < newSections.Count; s++) + { + var section = newSections[s]; + var startFixed = false; + var endFixed = false; + var newStart = section.StartPoint; + var newEnd = section.EndPoint; + + if (edge.SourceNodeId is not null && nodesById.TryGetValue(edge.SourceNodeId, out var srcNode) && s == 0) + { + if (newStart.X > srcNode.X + 1d && newStart.X < srcNode.X + srcNode.Width - 1d + && newStart.Y > srcNode.Y + 1d && newStart.Y < srcNode.Y + srcNode.Height - 1d) + { + var target = section.BendPoints.Count > 0 ? section.BendPoints.First() : section.EndPoint; + newStart = ElkShapeBoundaries.ProjectOntoShapeBoundary(srcNode, target); + startFixed = true; + } + } + + if (edge.TargetNodeId is not null && nodesById.TryGetValue(edge.TargetNodeId, out var tgtNode) && s == newSections.Count - 1) + { + var source = section.BendPoints.Count > 0 ? section.BendPoints.Last() : section.StartPoint; + var projected = ElkShapeBoundaries.ProjectOntoShapeBoundary(tgtNode, source); + if (Math.Abs(projected.X - newEnd.X) > 3d || Math.Abs(projected.Y - newEnd.Y) > 3d) + { + newEnd = projected; + endFixed = true; + } + } + + if (startFixed || endFixed) + { + anyChanged = true; + newSections[s] = new ElkEdgeSection + { + StartPoint = newStart, + EndPoint = newEnd, + BendPoints = section.BendPoints, + }; + } + } + + result[i] = anyChanged + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + internal static ElkRoutedEdge[] AvoidNodeCrossings( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0) + { + return edges; + } + + const double margin = 18d; + var obstacles = nodes.Select(n => ( + Left: n.X - margin, Top: n.Y - margin, + Right: n.X + n.Width + margin, Bottom: n.Y + n.Height + margin, + Id: n.Id + )).ToArray(); + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + + var result = new ElkRoutedEdge[edges.Length]; + for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) + { + var edge = edges[edgeIndex]; + var sourceId = edge.SourceNodeId ?? ""; + var targetId = edge.TargetNodeId ?? ""; + + var hasCorridorPts = HasCorridorBendPoints(edge, graphMinY, graphMaxY); + if (hasCorridorPts && IsRepeatCollectorLabel(edge.Label)) + { + result[edgeIndex] = edge; + continue; + } + + var hasCrossing = false; + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + for (var i = 0; i < pts.Count - 1 && !hasCrossing; i++) + { + if (hasCorridorPts && IsCorridorSegment(pts[i], pts[i + 1], graphMinY, graphMaxY)) + { + continue; + } + + hasCrossing = SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId); + } + } + + if (!hasCrossing) + { + result[edgeIndex] = edge; + continue; + } + + var hasCorridorPoints = hasCorridorPts; + var newSections = new List(edge.Sections.Count); + foreach (var section in edge.Sections) + { + if (hasCorridorPoints) + { + var corridorRerouted = ElkEdgePostProcessorCorridor.ReroutePreservingCorridor( + section, obstacles, sourceId, targetId, margin, graphMinY, graphMaxY); + if (corridorRerouted is not null) + { + newSections.Add(corridorRerouted); + continue; + } + } + + var rerouted = ElkEdgePostProcessorAStar.RerouteWithGridAStar( + section.StartPoint, section.EndPoint, + obstacles, sourceId, targetId, margin); + + if (rerouted is not null && rerouted.Count >= 2) + { + newSections.Add(new ElkEdgeSection + { + StartPoint = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }); + } + else + { + newSections.Add(section); + } + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + internal static ElkRoutedEdge[] EliminateDiagonalSegments(ElkRoutedEdge[] edges, ElkPositionedNode[] nodes) + { + var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; + var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray(); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var anyFixed = false; + var newSections = new List(); + + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + var fixedPts = new List { pts[0] }; + for (var j = 1; j < pts.Count; j++) + { + var prev = fixedPts[^1]; + var curr = pts[j]; + var dx = Math.Abs(curr.X - prev.X); + var dy = Math.Abs(curr.Y - prev.Y); + if (dx > 3d && dy > 3d) + { + var prevIsCorridor = prev.Y < graphMinY - 8d || prev.Y > graphMaxY + 8d; + var currIsCorridor = curr.Y < graphMinY - 8d || curr.Y > graphMaxY + 8d; + var isBackwardSection = section.EndPoint.X < section.StartPoint.X - 1d; + if (prevIsCorridor) + { + fixedPts.Add(new ElkPoint { X = curr.X, Y = prev.Y }); + anyFixed = true; + } + else if (currIsCorridor && isBackwardSection) + { + // Preserve diagonal for backward collector edges + } + else + { + fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y }); + anyFixed = true; + } + } + fixedPts.Add(curr); + } + + newSections.Add(new ElkEdgeSection + { + StartPoint = fixedPts[0], + EndPoint = fixedPts[^1], + BendPoints = fixedPts.Skip(1).Take(fixedPts.Count - 2).ToArray(), + }); + } + + result[i] = anyFixed + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + internal static bool IsRepeatCollectorLabel(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return false; + } + + var normalized = label.Trim().ToLowerInvariant(); + return normalized.StartsWith("repeat ", StringComparison.Ordinal) + || normalized.Equals("body", StringComparison.Ordinal); + } + + internal static bool IsCorridorSegment(ElkPoint p1, ElkPoint p2, double graphMinY, double graphMaxY) + { + return p1.Y < graphMinY - 8d || p1.Y > graphMaxY + 8d + || p2.Y < graphMinY - 8d || p2.Y > graphMaxY + 8d; + } + + internal static bool HasCorridorBendPoints(ElkRoutedEdge edge, double graphMinY, double graphMaxY) + { + foreach (var section in edge.Sections) + { + foreach (var bp in section.BendPoints) + { + if (bp.Y < graphMinY - 8d || bp.Y > graphMaxY + 8d) + { + return true; + } + } + } + + return false; + } + + internal static bool SegmentCrossesObstacle( + ElkPoint p1, ElkPoint p2, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId) + { + var segLen = Math.Sqrt(Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2)); + var isH = Math.Abs(p1.Y - p2.Y) < 2d; + var isV = Math.Abs(p1.X - p2.X) < 2d; + if (!isH && !isV) return segLen > 15d; + + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + if (isH && p1.Y > ob.Top && p1.Y < ob.Bottom) + { + var minX = Math.Min(p1.X, p2.X); + var maxX = Math.Max(p1.X, p2.X); + if (maxX > ob.Left && minX < ob.Right) return true; + } + else if (isV && p1.X > ob.Left && p1.X < ob.Right) + { + var minY = Math.Min(p1.Y, p2.Y); + var maxY = Math.Max(p1.Y, p2.Y); + if (maxY > ob.Top && minY < ob.Bottom) return true; + } + } + + return false; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorAStar.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorAStar.cs new file mode 100644 index 000000000..2ce4301f1 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorAStar.cs @@ -0,0 +1,159 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgePostProcessorAStar +{ + internal static List? RerouteWithGridAStar( + ElkPoint start, ElkPoint end, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId, + double margin) + { + var xs = new SortedSet { start.X, end.X }; + var ys = new SortedSet { start.Y, end.Y }; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + xs.Add(ob.Left - margin); + xs.Add(ob.Right + margin); + ys.Add(ob.Top - margin); + ys.Add(ob.Bottom + margin); + } + + var xArr = xs.ToArray(); + var yArr = ys.ToArray(); + var xCount = xArr.Length; + var yCount = yArr.Length; + if (xCount < 2 || yCount < 2) return null; + + var startIx = Array.BinarySearch(xArr, start.X); + var startIy = Array.BinarySearch(yArr, start.Y); + var endIx = Array.BinarySearch(xArr, end.X); + var endIy = Array.BinarySearch(yArr, end.Y); + if (startIx < 0 || startIy < 0 || endIx < 0 || endIy < 0) return null; + + bool IsBlocked(int ix1, int iy1, int ix2, int iy2) + { + var x1 = xArr[ix1]; var y1 = yArr[iy1]; + var x2 = xArr[ix2]; var y2 = yArr[iy2]; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + if (ix1 == ix2) + { + var segX = x1; + if (segX > ob.Left && segX < ob.Right) + { + var minY = Math.Min(y1, y2); + var maxY = Math.Max(y1, y2); + if (maxY > ob.Top && minY < ob.Bottom) return true; + } + } + else if (iy1 == iy2) + { + var segY = y1; + if (segY > ob.Top && segY < ob.Bottom) + { + var minX = Math.Min(x1, x2); + var maxX = Math.Max(x1, x2); + if (maxX > ob.Left && minX < ob.Right) return true; + } + } + } + + return false; + } + + // A* with (ix, iy, direction) state; direction: 0=none, 1=horizontal, 2=vertical + const double bendPenalty = 200d; + var stateCount = xCount * yCount * 3; + var gScore = new double[stateCount]; + Array.Fill(gScore, double.MaxValue); + var cameFrom = new int[stateCount]; + Array.Fill(cameFrom, -1); + + int StateId(int ix, int iy, int dir) => (ix * yCount + iy) * 3 + dir; + double Heuristic(int ix, int iy) => + Math.Abs(xArr[ix] - xArr[endIx]) + Math.Abs(yArr[iy] - yArr[endIy]); + + var startState = StateId(startIx, startIy, 0); + gScore[startState] = 0d; + var openSet = new PriorityQueue(); + openSet.Enqueue(startState, Heuristic(startIx, startIy)); + + var dx = new[] { 1, -1, 0, 0 }; + var dy = new[] { 0, 0, 1, -1 }; + var dirs = new[] { 1, 1, 2, 2 }; + + var maxIterations = xCount * yCount * 6; + var iterations = 0; + var closed = new HashSet(); + while (openSet.Count > 0 && iterations++ < maxIterations) + { + var current = openSet.Dequeue(); + + if (!closed.Add(current)) + { + continue; + } + + var curDir = current % 3; + var curIy = (current / 3) % yCount; + var curIx = (current / 3) / yCount; + + if (curIx == endIx && curIy == endIy) + { + var path = new List(); + var state = current; + while (state >= 0) + { + var sIy = (state / 3) % yCount; + var sIx = (state / 3) / yCount; + path.Add(new ElkPoint { X = xArr[sIx], Y = yArr[sIy] }); + state = cameFrom[state]; + } + + path.Reverse(); + var simplified = new List { path[0] }; + for (var i = 1; i < path.Count - 1; i++) + { + var prev = simplified[^1]; + var next = path[i + 1]; + if (Math.Abs(prev.X - path[i].X) > 0.5d || Math.Abs(path[i].X - next.X) > 0.5d) + { + if (Math.Abs(prev.Y - path[i].Y) > 0.5d || Math.Abs(path[i].Y - next.Y) > 0.5d) + { + simplified.Add(path[i]); + } + } + } + + simplified.Add(path[^1]); + return simplified; + } + + for (var d = 0; d < 4; d++) + { + var nx = curIx + dx[d]; + var ny = curIy + dy[d]; + if (nx < 0 || nx >= xCount || ny < 0 || ny >= yCount) continue; + if (IsBlocked(curIx, curIy, nx, ny)) continue; + + var newDir = dirs[d]; + var bend = (curDir != 0 && curDir != newDir) ? bendPenalty : 0d; + var dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]); + var tentativeG = gScore[current] + dist + bend; + var neighborState = StateId(nx, ny, newDir); + + if (tentativeG < gScore[neighborState]) + { + gScore[neighborState] = tentativeG; + cameFrom[neighborState] = current; + var f = tentativeG + Heuristic(nx, ny); + openSet.Enqueue(neighborState, f); + } + } + } + + return null; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs new file mode 100644 index 000000000..7e99b3d3e --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs @@ -0,0 +1,208 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgePostProcessorCorridor +{ + internal static ElkEdgeSection? ReroutePreservingCorridor( + ElkEdgeSection section, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId, double margin, + double graphMinY, double graphMaxY) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + var firstCorridorIndex = -1; + var lastCorridorIndex = -1; + for (var i = 0; i < pts.Count; i++) + { + if (pts[i].Y < graphMinY - 8d || pts[i].Y > graphMaxY + 8d) + { + if (firstCorridorIndex < 0) + { + firstCorridorIndex = i; + } + + lastCorridorIndex = i; + } + } + + if (firstCorridorIndex < 0 || firstCorridorIndex == 0 && lastCorridorIndex == pts.Count - 1) + { + return null; + } + + var corridorY = pts[firstCorridorIndex].Y; + var isAboveCorridor = corridorY < graphMinY - 8d; + var result = new List(); + + if (firstCorridorIndex > 0) + { + if (isAboveCorridor) + { + for (var i = 0; i < firstCorridorIndex; i++) + { + var last = result.Count > 0 ? result[^1] : (ElkPoint?)null; + if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d) + { + result.Add(pts[i]); + } + } + + var entryX = result.Count > 0 ? result[^1].X : section.StartPoint.X; + var entryY = result.Count > 0 ? result[^1].Y : section.StartPoint.Y; + var safeEntryX = FindSafeVerticalX(entryX, entryY, corridorY, obstacles, sourceId, targetId); + if (Math.Abs(safeEntryX - entryX) > 1d) + { + result.Add(new ElkPoint { X = safeEntryX, Y = corridorY }); + } + } + else + { + var entryTarget = pts[firstCorridorIndex]; + var entryPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar( + section.StartPoint, entryTarget, obstacles, sourceId, targetId, margin); + if (entryPath is not null && entryPath.Count >= 2) + { + result.AddRange(entryPath); + } + else + { + for (var i = 0; i <= firstCorridorIndex; i++) + { + result.Add(pts[i]); + } + } + } + } + else + { + result.Add(pts[0]); + } + + for (var i = firstCorridorIndex; i <= lastCorridorIndex; i++) + { + var last = result.Count > 0 ? result[^1] : (ElkPoint?)null; + if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d) + { + result.Add(pts[i]); + } + } + + if (lastCorridorIndex < pts.Count - 1) + { + if (isAboveCorridor) + { + for (var i = lastCorridorIndex + 1; i < pts.Count; i++) + { + var last = result.Count > 0 ? result[^1] : (ElkPoint?)null; + if (last is null || Math.Abs(last.X - pts[i].X) > 0.01d || Math.Abs(last.Y - pts[i].Y) > 0.01d) + { + result.Add(pts[i]); + } + } + } + else + { + var exitSource = pts[lastCorridorIndex]; + var exitPath = ElkEdgePostProcessorAStar.RerouteWithGridAStar( + exitSource, section.EndPoint, obstacles, sourceId, targetId, margin); + if (exitPath is not null && exitPath.Count >= 2) + { + for (var i = 1; i < exitPath.Count; i++) + { + result.Add(exitPath[i]); + } + } + else + { + for (var i = lastCorridorIndex + 1; i < pts.Count; i++) + { + result.Add(pts[i]); + } + } + } + } + + if (result.Count < 2) + { + return null; + } + + return new ElkEdgeSection + { + StartPoint = result[0], + EndPoint = result[^1], + BendPoints = result.Skip(1).Take(result.Count - 2).ToArray(), + }; + } + + internal static double FindSafeVerticalX( + double anchorX, double anchorY, double corridorY, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId) + { + var minY = Math.Min(anchorY, corridorY); + var maxY = Math.Max(anchorY, corridorY); + + var blocked = false; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) + { + continue; + } + + if (anchorX > ob.Left && anchorX < ob.Right && maxY > ob.Top && minY < ob.Bottom) + { + blocked = true; + break; + } + } + + if (!blocked) + { + return anchorX; + } + + var candidateRight = anchorX; + var candidateLeft = anchorX; + for (var attempt = 0; attempt < 20; attempt++) + { + candidateRight += 24d; + if (!IsVerticalBlocked(candidateRight, minY, maxY, obstacles, sourceId, targetId)) + { + return candidateRight; + } + + candidateLeft -= 24d; + if (!IsVerticalBlocked(candidateLeft, minY, maxY, obstacles, sourceId, targetId)) + { + return candidateLeft; + } + } + + return anchorX; + } + + private static bool IsVerticalBlocked( + double x, double minY, double maxY, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId) + { + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) + { + continue; + } + + if (x > ob.Left && x < ob.Right && maxY > ob.Top && minY < ob.Bottom) + { + return true; + } + } + + return false; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs new file mode 100644 index 000000000..5a7d7b59e --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs @@ -0,0 +1,334 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgePostProcessorSimplify +{ + internal static ElkRoutedEdge[] SimplifyEdgePaths( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray(); + var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d; + var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d; + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var excludeIds = new HashSet(StringComparer.Ordinal) { edge.SourceNodeId ?? "", edge.TargetNodeId ?? "" }; + var anyChanged = false; + var newSections = new List(edge.Sections.Count); + var hasCorridor = ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY); + + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + // Pass 1: Remove collinear points + var cleaned = new List { pts[0] }; + for (var j = 1; j < pts.Count - 1; j++) + { + var prev = cleaned[^1]; + var curr = pts[j]; + var next = pts[j + 1]; + var sameX = Math.Abs(prev.X - curr.X) < 1d && Math.Abs(curr.X - next.X) < 1d; + var sameY = Math.Abs(prev.Y - curr.Y) < 1d && Math.Abs(curr.Y - next.Y) < 1d; + if (sameX || sameY) + { + anyChanged = true; + } + else + { + cleaned.Add(curr); + } + } + cleaned.Add(pts[^1]); + + // Pass 2: Try L-shape shortcuts for each triple (skip for corridor-routed edges) + var changed = !hasCorridor; + var simplifyPass = 0; + while (changed && simplifyPass++ < 20) + { + changed = false; + for (var j = 0; j + 2 < cleaned.Count; j++) + { + var a = cleaned[j]; + var c = cleaned[j + 2]; + var corner1 = new ElkPoint { X = a.X, Y = c.Y }; + var corner2 = new ElkPoint { X = c.X, Y = a.Y }; + + foreach (var corner in new[] { corner1, corner2 }) + { + if (SegmentClearsObstacles(a, corner, obstacles, excludeIds) + && SegmentClearsObstacles(corner, c, obstacles, excludeIds)) + { + cleaned[j + 1] = corner; + changed = true; + anyChanged = true; + break; + } + } + } + } + + // Remove trailing duplicates (bend point == endpoint) + while (cleaned.Count > 2 + && Math.Abs(cleaned[^1].X - cleaned[^2].X) < 1d + && Math.Abs(cleaned[^1].Y - cleaned[^2].Y) < 1d) + { + cleaned.RemoveAt(cleaned.Count - 2); + } + + // Remove leading duplicates (start point == first bend) + while (cleaned.Count > 2 + && Math.Abs(cleaned[0].X - cleaned[1].X) < 1d + && Math.Abs(cleaned[0].Y - cleaned[1].Y) < 1d) + { + cleaned.RemoveAt(1); + } + + newSections.Add(new ElkEdgeSection + { + StartPoint = cleaned[0], + EndPoint = cleaned[^1], + BendPoints = cleaned.Skip(1).Take(cleaned.Count - 2).ToArray(), + }); + } + + result[i] = anyChanged + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + internal static ElkRoutedEdge[] TightenOuterCorridors( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (nodes.Length == 0) return edges; + + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + const double minMargin = 12d; + const double laneGap = 8d; + + var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>(); + for (var i = 0; i < edges.Length; i++) + { + var aboveYs = new List(); + var belowYs = new List(); + foreach (var section in edges[i].Sections) + { + foreach (var bp in section.BendPoints) + { + if (bp.Y < graphMinY - 8d) + { + aboveYs.Add(bp.Y); + } + else if (bp.Y > graphMaxY + 8d) + { + belowYs.Add(bp.Y); + } + } + } + + if (aboveYs.Count > 0) + { + outerEdges.Add((i, aboveYs.Min(), true)); + } + + if (belowYs.Count > 0) + { + outerEdges.Add((i, belowYs.Max(), false)); + } + } + + if (outerEdges.Count == 0) return edges; + + NormalizeCorridorYValues(outerEdges, edges, graphMinY, graphMaxY); + + var aboveLanes = outerEdges.Where(e => e.IsAbove) + .GroupBy(e => Math.Round(e.CorridorY, 1)) + .OrderBy(g => g.Key) + .ToArray(); + var belowLanes = outerEdges.Where(e => !e.IsAbove) + .GroupBy(e => Math.Round(e.CorridorY, 1)) + .OrderByDescending(g => g.Key) + .ToArray(); + + var result = edges.ToArray(); + var shifts = new Dictionary(); + + for (var lane = 0; lane < aboveLanes.Length; lane++) + { + var targetY = graphMinY - minMargin - (lane * laneGap); + var currentY = aboveLanes[lane].Key; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + foreach (var entry in aboveLanes[lane]) + { + shifts[entry.Index] = shift; + } + } + } + + for (var lane = 0; lane < belowLanes.Length; lane++) + { + var targetY = graphMaxY + minMargin + (lane * laneGap); + var currentY = belowLanes[lane].Key; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + foreach (var entry in belowLanes[lane]) + { + shifts[entry.Index] = shift; + } + } + } + + foreach (var (edgeIndex, shift) in shifts) + { + var edge = result[edgeIndex]; + var boundary = shift > 0 ? graphMaxY : graphMinY; + var newSections = new List(); + foreach (var section in edge.Sections) + { + var newBendPoints = section.BendPoints.Select(bp => + { + if ((shift < 0 && bp.Y < graphMinY - 4d) || (shift > 0 && bp.Y > graphMaxY + 4d) + || (shift > 0 && bp.Y < graphMinY - 4d) || (shift < 0 && bp.Y > graphMaxY + 4d)) + { + return new ElkPoint { X = bp.X, Y = bp.Y + shift }; + } + return bp; + }).ToArray(); + + newSections.Add(new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = newBendPoints, + }); + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + internal static bool SegmentClearsObstacles( + ElkPoint p1, ElkPoint p2, + (double L, double T, double R, double B, string Id)[] obstacles, + HashSet excludeIds) + { + var isH = Math.Abs(p1.Y - p2.Y) < 1d; + var isV = Math.Abs(p1.X - p2.X) < 1d; + if (!isH && !isV) return true; + + foreach (var ob in obstacles) + { + if (excludeIds.Contains(ob.Id)) continue; + if (isH && p1.Y > ob.T && p1.Y < ob.B) + { + if (Math.Max(p1.X, p2.X) > ob.L && Math.Min(p1.X, p2.X) < ob.R) return false; + } + else if (isV && p1.X > ob.L && p1.X < ob.R) + { + if (Math.Max(p1.Y, p2.Y) > ob.T && Math.Min(p1.Y, p2.Y) < ob.B) return false; + } + } + return true; + } + + private static void NormalizeCorridorYValues( + List<(int Index, double CorridorY, bool IsAbove)> outerEdges, + ElkRoutedEdge[] edges, + double graphMinY, double graphMaxY) + { + const double mergeThreshold = 6d; + var groups = new List>(); + var sorted = outerEdges.OrderBy(e => e.CorridorY).ToArray(); + foreach (var entry in sorted) + { + var merged = false; + foreach (var group in groups) + { + var groupY = outerEdges[group[0]].CorridorY; + if (Math.Abs(entry.CorridorY - groupY) <= mergeThreshold && entry.IsAbove == outerEdges[group[0]].IsAbove) + { + group.Add(outerEdges.IndexOf(entry)); + merged = true; + break; + } + } + + if (!merged) + { + groups.Add([outerEdges.IndexOf(entry)]); + } + } + + foreach (var group in groups) + { + if (group.Count <= 1) + { + continue; + } + + var targetY = outerEdges[group[0]].CorridorY; + for (var gi = 1; gi < group.Count; gi++) + { + var idx = group[gi]; + var edgeIndex = outerEdges[idx].Index; + var currentY = outerEdges[idx].CorridorY; + var shift = targetY - currentY; + if (Math.Abs(shift) < 0.5d) + { + continue; + } + + var edge = edges[edgeIndex]; + var newSections = new List(); + foreach (var section in edge.Sections) + { + var newBendPoints = section.BendPoints.Select(bp => + { + if (Math.Abs(bp.Y - currentY) < 2d) + { + return new ElkPoint { X = bp.X, Y = targetY }; + } + + return bp; + }).ToArray(); + + newSections.Add(new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = newBendPoints, + }); + } + + edges[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + outerEdges[idx] = (edgeIndex, targetY, outerEdges[idx].IsAbove); + } + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs new file mode 100644 index 000000000..8399c134f --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs @@ -0,0 +1,278 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeRouter +{ + internal static ElkRoutedEdge RouteEdge( + ElkEdge edge, + IReadOnlyDictionary nodesById, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + + var (sourceSide, targetSide) = ElkEdgeRouterAnchors.ResolveRouteSides(sourceNode, targetNode, direction); + var sourcePoint = ElkEdgeRouterAnchors.ResolveAnchorPoint(sourceNode, targetNode, edge.SourcePortId, direction, sourceSide); + var targetPoint = ElkEdgeRouterAnchors.ResolveAnchorPoint(targetNode, sourceNode, edge.TargetPortId, direction, targetSide); + + if (channel.RouteMode == EdgeRouteMode.BackwardOuter + && string.IsNullOrWhiteSpace(edge.SourcePortId) + && string.Equals(sourceSide, "NORTH", StringComparison.Ordinal) + && direction == ElkLayoutDirection.LeftToRight) + { + var rightInsetX = Math.Min( + sourceNode.X + sourceNode.Width - Math.Min(18d, sourceNode.Width / 4d), + sourceNode.X + sourceNode.Width - 6d); + sourcePoint = new ElkPoint { X = rightInsetX, Y = sourcePoint.Y }; + } + + if (string.IsNullOrWhiteSpace(edge.SourcePortId) + && string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.RouteMode == EdgeRouteMode.Direct) + { + (sourcePoint, targetPoint) = ElkEdgeRouterAnchors.ResolveStraightChainAnchors( + sourceNode, + targetNode, + sourcePoint, + targetPoint, + sourceSide, + targetSide, + channel, + direction); + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.TargetIncomingCount > 1 + && direction == ElkLayoutDirection.LeftToRight + && targetPoint.X >= sourcePoint.X) + { + var insetY = Math.Min(16d, (targetNode.Height - 12d) / Math.Max(1, channel.TargetIncomingCount)); + var totalInset = (channel.TargetIncomingCount - 1) * insetY; + var adjustedY = (targetNode.Y + (targetNode.Height / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetY); + adjustedY = ElkLayoutHelpers.Clamp(adjustedY, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d); + targetPoint = new ElkPoint { X = targetPoint.X, Y = adjustedY }; + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.TargetIncomingCount > 1 + && direction == ElkLayoutDirection.TopToBottom + && targetPoint.Y >= sourcePoint.Y) + { + var insetX = Math.Min(16d, (targetNode.Width - 12d) / Math.Max(1, channel.TargetIncomingCount)); + var totalInset = (channel.TargetIncomingCount - 1) * insetX; + var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetX); + adjustedX = ElkLayoutHelpers.Clamp(adjustedX, targetNode.X + 6d, targetNode.X + targetNode.Width - 6d); + targetPoint = new ElkPoint { X = adjustedX, Y = targetPoint.Y }; + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.BackwardTargetCount > 1 + && targetPoint.X < sourcePoint.X + && direction == ElkLayoutDirection.LeftToRight) + { + var spread = Math.Min(24d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); + var totalSpread = (channel.BackwardTargetCount - 1) * spread; + var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); + adjustedX = ElkLayoutHelpers.Clamp(adjustedX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); + targetPoint = new ElkPoint { X = adjustedX, Y = targetNode.Y }; + } + + var bendPoints = direction == ElkLayoutDirection.LeftToRight + ? ElkEdgeRouterBendPoints.BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId) + : ElkEdgeRouterBendPoints.BuildVerticalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId); + + var routedKind = channel.RouteMode == EdgeRouteMode.BackwardOuter + ? $"backward|usc={channel.UseSourceCollector}|sox={channel.SharedOuterX:F0}" + : edge.Kind; + + return new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = routedKind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = sourcePoint, + EndPoint = targetPoint, + BendPoints = bendPoints, + }, + ], + }; + } + + internal static Dictionary ReconstructDummyEdges( + IReadOnlyCollection originalEdges, + DummyNodeResult dummyResult, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary augmentedNodesById, + ElkLayoutDirection direction, + GraphBounds graphBounds, + IReadOnlyDictionary edgeChannels, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var edgesWithChains = originalEdges + .Where(e => dummyResult.EdgeDummyChains.ContainsKey(e.Id)) + .ToArray(); + + var incomingByTarget = edgesWithChains + .GroupBy(e => e.TargetNodeId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(e => + { + var s = positionedNodes[e.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? s.Y + (s.Height / 2d) + : s.X + (s.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var outgoingBySource = edgesWithChains + .GroupBy(e => e.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(e => + { + var t = positionedNodes[e.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? t.Y + (t.Height / 2d) + : t.X + (t.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var reconstructed = new Dictionary(StringComparer.Ordinal); + + foreach (var edge in edgesWithChains) + { + if (!dummyResult.EdgeDummyChains.TryGetValue(edge.Id, out var chain) || chain.Count == 0) + { + continue; + } + + var channel = edgeChannels.GetValueOrDefault(edge.Id); + if (channel.RouteMode != EdgeRouteMode.Direct + || !string.IsNullOrWhiteSpace(edge.SourcePortId) + || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + var sourceGroup = outgoingBySource.GetValueOrDefault(edge.SourceNodeId); + var targetGroup = incomingByTarget.GetValueOrDefault(edge.TargetNodeId); + + if (ShouldRouteLongEdgeViaDirectRouter(edge, sourceNode, targetNode, sourceGroup, targetGroup, positionedNodes, direction)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var bendPoints = new List(); + foreach (var dummyId in chain) + { + if (positionedNodes.TryGetValue(dummyId, out var dummyPos)) + { + bendPoints.Add(new ElkPoint + { + X = dummyPos.X + (dummyPos.Width / 2d), + Y = dummyPos.Y + (dummyPos.Height / 2d), + }); + } + } + + var sourceExitY = ElkEdgeRouterGrouping.ResolveGroupedAnchorCoordinate(sourceNode, edge, sourceGroup, positionedNodes, isSource: true, direction); + var targetEntryY = ElkEdgeRouterGrouping.ResolveGroupedAnchorCoordinate(targetNode, edge, targetGroup, positionedNodes, isSource: false, direction); + + var targetCenter = new ElkPoint + { + X = targetNode.X + (targetNode.Width / 2d), + Y = targetNode.Y + (targetNode.Height / 2d), + }; + var sourceAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor(sourceNode, targetCenter, + true, sourceExitY, sourceGroup?.Length ?? 1, direction); + var targetAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null, + false, targetEntryY, targetGroup?.Length ?? 1, direction); + + reconstructed[edge.Id] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = sourceAnchor, + EndPoint = targetAnchor, + BendPoints = bendPoints, + }, + ], + }; + } + + return reconstructed; + } + + internal static bool ShouldRouteLongEdgeViaDirectRouter( + ElkEdge edge, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyList? sourceGroup, + IReadOnlyList? targetGroup, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var rowTolerance = Math.Max(28d, Math.Min(sourceNode.Height, targetNode.Height) * 0.4d); + if (Math.Abs(sourceCenterY - targetCenterY) <= rowTolerance) + { + return true; + } + + var sourcePrimaryIndex = ElkEdgeRouterGrouping.ResolvePrimaryAxisGroupIndex(sourceNode, sourceGroup, positionedNodes, isSource: true, direction); + var targetPrimaryIndex = ElkEdgeRouterGrouping.ResolvePrimaryAxisGroupIndex(targetNode, targetGroup, positionedNodes, isSource: false, direction); + if (sourcePrimaryIndex < 0 && targetPrimaryIndex < 0) + { + return false; + } + + var sourceIndex = sourceGroup is null + ? -1 + : Array.FindIndex(sourceGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + var targetIndex = targetGroup is null + ? -1 + : Array.FindIndex(targetGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + + return sourceIndex >= 0 && sourceIndex == sourcePrimaryIndex + || targetIndex >= 0 && targetIndex == targetPrimaryIndex; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAnchors.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAnchors.cs new file mode 100644 index 000000000..03943e5f3 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAnchors.cs @@ -0,0 +1,260 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeRouterAnchors +{ + internal static ElkPoint ResolveAnchorPoint( + ElkPositionedNode node, + ElkPositionedNode otherNode, + string? portId, + ElkLayoutDirection direction, + string? forcedSide = null) + { + if (!string.IsNullOrWhiteSpace(portId)) + { + var port = node.Ports.FirstOrDefault(x => string.Equals(x.Id, portId, StringComparison.Ordinal)); + if (port is not null) + { + return new ElkPoint + { + X = port.X + (port.Width / 2d), + Y = port.Y + (port.Height / 2d), + }; + } + } + + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var otherCenterX = otherNode.X + (otherNode.Width / 2d); + var otherCenterY = otherNode.Y + (otherNode.Height / 2d); + + if (Math.Abs(otherCenterX - nodeCenterX) < 0.001d + && Math.Abs(otherCenterY - nodeCenterY) < 0.001d) + { + return new ElkPoint + { + X = nodeCenterX, + Y = nodeCenterY, + }; + } + + return ResolvePreferredAnchorPoint(node, otherCenterX, otherCenterY, forcedSide, direction); + } + + internal static (ElkPoint SourcePoint, ElkPoint TargetPoint) ResolveStraightChainAnchors( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint sourcePoint, + ElkPoint targetPoint, + string sourceSide, + string targetSide, + EdgeChannel channel, + ElkLayoutDirection direction) + { + if (direction == ElkLayoutDirection.LeftToRight) + { + if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.X < sourcePoint.X) + { + return (sourcePoint, targetPoint); + } + + var sharedY = sourceNode.Y + (sourceNode.Height / 2d); + return ( + ResolvePreferredAnchorPoint(sourceNode, targetNode.X, sharedY, sourceSide, direction), + ResolvePreferredAnchorPoint(targetNode, sourceNode.X + sourceNode.Width, sharedY, targetSide, direction)); + } + + if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.Y < sourcePoint.Y) + { + return (sourcePoint, targetPoint); + } + + var sharedX = sourceNode.X + (sourceNode.Width / 2d); + return ( + ResolvePreferredAnchorPoint(sourceNode, sharedX, targetNode.Y, sourceSide, direction), + ResolvePreferredAnchorPoint(targetNode, sharedX, sourceNode.Y + sourceNode.Height, targetSide, direction)); + } + + internal static (string SourceSide, string TargetSide) ResolveRouteSides( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkLayoutDirection direction) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + + if (direction == ElkLayoutDirection.LeftToRight) + { + if (Math.Abs(deltaX) >= 24d || Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d) + { + return deltaX >= 0d + ? ("EAST", "WEST") + : ("NORTH", "NORTH"); + } + + return deltaY >= 0d + ? ("SOUTH", "NORTH") + : ("NORTH", "SOUTH"); + } + + if (Math.Abs(deltaY) >= 24d || Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d) + { + return deltaY >= 0d + ? ("SOUTH", "NORTH") + : ("NORTH", "NORTH"); + } + + return deltaX >= 0d + ? ("EAST", "WEST") + : ("WEST", "EAST"); + } + + internal static ElkPoint ResolvePreferredAnchorPoint( + ElkPositionedNode node, + double targetX, + double targetY, + string? forcedSide, + ElkLayoutDirection direction) + { + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var deltaX = targetX - nodeCenterX; + var deltaY = targetY - nodeCenterY; + var insetX = Math.Min(18d, node.Width / 4d); + var insetY = Math.Min(18d, node.Height / 4d); + + var preferredSide = forcedSide; + if (string.IsNullOrWhiteSpace(preferredSide)) + { + preferredSide = direction == ElkLayoutDirection.LeftToRight + ? (Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d + ? (deltaX >= 0d ? "EAST" : "WEST") + : (deltaY >= 0d ? "SOUTH" : "NORTH")) + : (Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d + ? (deltaY >= 0d ? "SOUTH" : "NORTH") + : (deltaX >= 0d ? "EAST" : "WEST")); + } + + var preferredTargetX = preferredSide switch + { + "EAST" => node.X + node.Width + 256d, + "WEST" => node.X - 256d, + _ => ElkLayoutHelpers.Clamp(targetX, node.X + insetX, node.X + node.Width - insetX), + }; + var preferredTargetY = preferredSide switch + { + "SOUTH" => node.Y + node.Height + 256d, + "NORTH" => node.Y - 256d, + _ => ElkLayoutHelpers.Clamp(targetY, node.Y + insetY, node.Y + node.Height - insetY), + }; + + var adjustedDeltaX = preferredTargetX - nodeCenterX; + var adjustedDeltaY = preferredTargetY - nodeCenterY; + + var candidate = new ElkPoint + { + X = preferredSide switch + { + "EAST" => node.X + node.Width, + "WEST" => node.X, + _ => ElkLayoutHelpers.Clamp(preferredTargetX, node.X + insetX, node.X + node.Width - insetX), + }, + Y = preferredSide switch + { + "SOUTH" => node.Y + node.Height, + "NORTH" => node.Y, + _ => ElkLayoutHelpers.Clamp(preferredTargetY, node.Y + insetY, node.Y + node.Height - insetY), + }, + }; + + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, candidate, adjustedDeltaX, adjustedDeltaY); + } + + internal static ElkPoint ComputeSmartAnchor( + ElkPositionedNode node, + ElkPoint? approachPoint, + bool isSource, + double spreadY, + int groupSize, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight || approachPoint is null) + { + var fallback = isSource + ? new ElkPoint { X = node.X + node.Width, Y = ElkLayoutHelpers.Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) } + : new ElkPoint { X = node.X, Y = ElkLayoutHelpers.Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint( + node, + fallback, + fallback.X - (node.X + (node.Width / 2d)), + fallback.Y - (node.Y + (node.Height / 2d))); + } + + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var deltaX = approachPoint.X - nodeCenterX; + var deltaY = approachPoint.Y - nodeCenterY; + + if (isSource) + { + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY < 0d) + { + var topCandidate = new ElkPoint + { + X = ElkLayoutHelpers.Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y, + }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY > 0d) + { + var bottomCandidate = new ElkPoint + { + X = ElkLayoutHelpers.Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y + node.Height, + }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); + } + + var eastCandidate = new ElkPoint + { + X = node.X + node.Width, + Y = groupSize > 1 ? ElkLayoutHelpers.Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) + : ElkLayoutHelpers.Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), + }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, eastCandidate, eastCandidate.X - nodeCenterX, eastCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY < 0d) + { + var topCandidate = new ElkPoint + { + X = ElkLayoutHelpers.Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y, + }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY > 0d) + { + var bottomCandidate = new ElkPoint + { + X = ElkLayoutHelpers.Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y + node.Height, + }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); + } + + var westCandidate = new ElkPoint + { + X = node.X, + Y = groupSize > 1 ? ElkLayoutHelpers.Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) + : ElkLayoutHelpers.Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), + }; + return ElkShapeBoundaries.ResolveGatewayBoundaryPoint(node, westCandidate, westCandidate.X - nodeCenterX, westCandidate.Y - nodeCenterY); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs new file mode 100644 index 000000000..7c8343714 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs @@ -0,0 +1,274 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeRouterBendPoints +{ + internal static IReadOnlyCollection BuildHorizontalBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + if (channel.RouteMode == EdgeRouteMode.SinkOuter) + { + return BuildHorizontalSinkBendPoints(sourceNode, targetNode, startPoint, endPoint, graphBounds, channel, layerBoundariesByNodeId); + } + + if (channel.RouteMode == EdgeRouteMode.SinkOuterTop) + { + return BuildHorizontalTopSinkBendPoints(sourceNode, targetNode, startPoint, endPoint, graphBounds, channel, layerBoundariesByNodeId); + } + + if (Math.Abs(endPoint.Y - startPoint.Y) <= 6d && endPoint.X >= startPoint.X) + { + return []; + } + + if (channel.RouteMode == EdgeRouteMode.BackwardOuter || endPoint.X < startPoint.X) + { + return BuildHorizontalBackwardBendPoints(sourceNode, targetNode, startPoint, endPoint, graphBounds, channel, layerBoundariesByNodeId); + } + + var baseChannelX = ResolveForwardChannelX(sourceNode, targetNode, startPoint, endPoint, channel); + var channelX = ElkLayoutHelpers.Clamp(baseChannelX, startPoint.X + 12d, endPoint.X - 12d); + + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = channelX, Y = startPoint.Y }, + new ElkPoint { X = channelX, Y = endPoint.Y }); + } + + internal static IReadOnlyCollection BuildVerticalBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + if (Math.Abs(endPoint.X - startPoint.X) <= 6d && endPoint.Y >= startPoint.Y) + { + return []; + } + + if (endPoint.Y < startPoint.Y) + { + var lane = Math.Max(0, channel.BackwardLane); + var outerX = graphBounds.MinX - 48d - (lane * 24d); + + if (channel.BackwardTargetCount > 1) + { + var spread = Math.Min(18d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); + var totalSpread = (channel.BackwardTargetCount - 1) * spread; + var adjustedEndX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); + adjustedEndX = ElkLayoutHelpers.Clamp(adjustedEndX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = outerX, Y = startPoint.Y }, + new ElkPoint { X = outerX, Y = endPoint.Y }, + new ElkPoint { X = adjustedEndX, Y = endPoint.Y }); + } + + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = outerX, Y = startPoint.Y }, + new ElkPoint { X = outerX, Y = endPoint.Y }); + } + + var baseChannelY = (sourceNode.Y + sourceNode.Height + targetNode.Y) / 2d; + if (channel.ForwardCount > 1) + { + var totalHeight = (channel.ForwardCount - 1) * 16d; + var offset = (channel.ForwardIndex * 16d) - (totalHeight / 2d); + baseChannelY += offset; + } + + var channelY = ElkLayoutHelpers.Clamp(baseChannelY, startPoint.Y + 12d, endPoint.Y - 12d); + + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = startPoint.X, Y = channelY }, + new ElkPoint { X = endPoint.X, Y = channelY }); + } + + internal static double ResolveForwardChannelX( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + EdgeChannel channel) + { + if (!double.IsNaN(channel.PreferredDirectChannelX)) + { + return channel.PreferredDirectChannelX; + } + + if (ShouldPreferSourceLocalForwardDrop(sourceNode, targetNode, startPoint, endPoint, channel)) + { + var sourceLocalBase = Math.Max( + startPoint.X + 24d, + sourceNode.X + sourceNode.Width + 36d); + return sourceLocalBase + (channel.ForwardIndex * 36d); + } + + var baseChannelX = (sourceNode.X + sourceNode.Width + targetNode.X) / 2d; + if (channel.ForwardCount > 1) + { + var totalWidth = (channel.ForwardCount - 1) * 16d; + var offset = (channel.ForwardIndex * 16d) - (totalWidth / 2d); + baseChannelX += offset; + } + + return baseChannelX; + } + + internal static double ResolveForwardSourceExitX( + ElkPositionedNode sourceNode, + ElkPoint startPoint, + EdgeChannel channel, + double baseOffset, + double spread) + { + var sourceLocalBase = Math.Max( + startPoint.X + 24d, + sourceNode.X + sourceNode.Width + baseOffset); + if (channel.ForwardCount <= 1) + { + return sourceLocalBase; + } + + return sourceLocalBase + (channel.ForwardIndex * spread); + } + + internal static bool ShouldPreferSourceLocalForwardDrop( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + EdgeChannel channel) + { + if (channel.RouteMode != EdgeRouteMode.Direct || channel.ForwardCount <= 1) + { + return false; + } + + var verticalSpan = Math.Abs(endPoint.Y - startPoint.Y); + if (verticalSpan < 120d) + { + return false; + } + + var horizontalSpan = endPoint.X - startPoint.X; + if (horizontalSpan < 180d) + { + return false; + } + + return string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || verticalSpan > horizontalSpan * 0.45d; + } + + internal static IReadOnlyCollection BuildHorizontalBackwardBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var lane = Math.Max(0, channel.BackwardLane); + var isLowerCorridor = !double.IsNaN(channel.PreferredOuterY) + && channel.PreferredOuterY > Math.Max(startPoint.Y, endPoint.Y) + 4d; + var outerY = double.IsNaN(channel.PreferredOuterY) + ? graphBounds.MinY - 56d - (lane * 28d) + : isLowerCorridor + ? channel.PreferredOuterY + (lane * 24d) + : channel.PreferredOuterY - (lane * 24d); + var sourceBoundary = ElkLayoutHelpers.ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); + var sourceExitX = channel.UseSourceCollector + ? channel.SharedOuterX > 0d ? channel.SharedOuterX : Math.Max(startPoint.X + 18d, sourceBoundary.MaxX + 28d) + : channel.SharedOuterX > 0d + ? Math.Max(startPoint.X + 18d, channel.SharedOuterX) + : Math.Max(startPoint.X + 18d, sourceBoundary.MaxX + 28d); + var approachX = endPoint.X; + + if (channel.UseSourceCollector) + { + var collectorY = outerY + Math.Min(14d, Math.Abs(outerY - Math.Min(startPoint.Y, endPoint.Y)) * 0.2d); + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = collectorY }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = approachX, Y = outerY }); + } + + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = startPoint.X, Y = outerY }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = approachX, Y = outerY }); + } + + internal static IReadOnlyCollection BuildHorizontalSinkBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var targetBoundary = ElkLayoutHelpers.ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); + var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 36d, 36d); + var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); + var outerY = graphBounds.MaxY + 32d + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex)); + + var horizontalSpan = targetApproachX - sourceExitX; + if (horizontalSpan > 200d) + { + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = targetApproachX, Y = startPoint.Y }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = startPoint.Y }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + internal static IReadOnlyCollection BuildHorizontalTopSinkBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var sourceBoundary = ElkLayoutHelpers.ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); + var targetBoundary = ElkLayoutHelpers.ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); + var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 40d, 40d); + var sinkOffset = Math.Max(0, channel.SinkBandIndex) * 14d; + var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d - sinkOffset); + var outerY = double.IsNaN(channel.PreferredOuterY) + ? graphBounds.MinY - 56d - ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 36d, 28d) + : channel.PreferredOuterY + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d); + + var topHorizontalSpan = targetApproachX - sourceExitX; + if (topHorizontalSpan > 200d) + { + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = targetApproachX, Y = startPoint.Y }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + return ElkLayoutHelpers.NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = startPoint.Y }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterGrouping.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterGrouping.cs new file mode 100644 index 000000000..aebc0d586 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterGrouping.cs @@ -0,0 +1,97 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkEdgeRouterGrouping +{ + internal static double ResolveGroupedAnchorCoordinate( + ElkPositionedNode node, + ElkEdge edge, + IReadOnlyList? group, + IReadOnlyDictionary positionedNodes, + bool isSource, + ElkLayoutDirection direction) + { + var center = direction == ElkLayoutDirection.LeftToRight + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + if (group is null || group.Count <= 1) + { + return center; + } + + var index = Array.FindIndex(group.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + if (index < 0) + { + return center; + } + + var primaryIndex = ResolvePrimaryAxisGroupIndex(node, group, positionedNodes, isSource, direction); + if (primaryIndex >= 0) + { + var spread = direction == ElkLayoutDirection.LeftToRight + ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) + : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); + var coordinate = center + ((index - primaryIndex) * spread); + return direction == ElkLayoutDirection.LeftToRight + ? ElkLayoutHelpers.Clamp(coordinate, node.Y + 6d, node.Y + node.Height - 6d) + : ElkLayoutHelpers.Clamp(coordinate, node.X + 6d, node.X + node.Width - 6d); + } + + var fallbackSpread = direction == ElkLayoutDirection.LeftToRight + ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) + : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); + var total = (group.Count - 1) * fallbackSpread; + var fallback = center - (total / 2d) + (index * fallbackSpread); + return direction == ElkLayoutDirection.LeftToRight + ? ElkLayoutHelpers.Clamp(fallback, node.Y + 6d, node.Y + node.Height - 6d) + : ElkLayoutHelpers.Clamp(fallback, node.X + 6d, node.X + node.Width - 6d); + } + + internal static int ResolvePrimaryAxisGroupIndex( + ElkPositionedNode node, + IReadOnlyList? group, + IReadOnlyDictionary positionedNodes, + bool isSource, + ElkLayoutDirection direction) + { + if (group is null || group.Count == 0) + { + return -1; + } + + var reserveAxis = isSource + ? string.Equals(node.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + : string.Equals(node.Kind, "Join", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase); + if (!reserveAxis) + { + return -1; + } + + var center = direction == ElkLayoutDirection.LeftToRight + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + var bestIndex = 0; + var bestDistance = double.PositiveInfinity; + for (var index = 0; index < group.Count; index++) + { + var adjacentNodeId = isSource ? group[index].TargetNodeId : group[index].SourceNodeId; + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + var adjacentCenter = direction == ElkLayoutDirection.LeftToRight + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d); + var distance = Math.Abs(adjacentCenter - center); + if (distance < bestDistance) + { + bestDistance = distance; + bestIndex = index; + } + } + + return bestIndex; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs b/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs new file mode 100644 index 000000000..1c7df27ef --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs @@ -0,0 +1,43 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkGraphValidator +{ + internal static void ValidateGraph(ElkGraph graph) + { + if (graph.Nodes.Count == 0) + { + throw new InvalidOperationException("ElkSharp requires at least one node."); + } + + var duplicateNodeId = graph.Nodes + .GroupBy(x => x.Id, StringComparer.Ordinal) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateNodeId is not null) + { + throw new InvalidOperationException($"ElkSharp requires unique node ids. Duplicate '{duplicateNodeId.Key}' was found."); + } + + if (graph.Nodes.Any(x => !string.IsNullOrWhiteSpace(x.ParentNodeId))) + { + throw new NotSupportedException("ElkSharp currently supports flat graphs only. Compound nodes are not implemented in this spike."); + } + + var nodeIds = graph.Nodes.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); + foreach (var edge in graph.Edges) + { + if (!nodeIds.Contains(edge.SourceNodeId) || !nodeIds.Contains(edge.TargetNodeId)) + { + throw new InvalidOperationException($"Edge '{edge.Id}' references an unknown node."); + } + } + } + + internal static GraphBounds ComputeGraphBounds(ICollection nodes) + { + return new GraphBounds( + nodes.Min(n => n.X), + nodes.Min(n => n.Y), + nodes.Max(n => n.X + n.Width), + nodes.Max(n => n.Y + n.Height)); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs new file mode 100644 index 000000000..7896aa0b7 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs @@ -0,0 +1,222 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkLayerAssignment +{ + internal static (Dictionary InputOrder, HashSet BackEdgeIds) BuildTraversalInputOrder( + IReadOnlyCollection nodes, + IReadOnlyCollection edges, + IReadOnlyDictionary nodesById) + { + var originalOrder = nodes + .Select((node, index) => new KeyValuePair(node.Id, index)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + var outgoing = nodes.ToDictionary(node => node.Id, _ => new List(), StringComparer.Ordinal); + var incomingCount = nodes.ToDictionary(node => node.Id, _ => 0, StringComparer.Ordinal); + + foreach (var edge in edges) + { + outgoing[edge.SourceNodeId].Add(edge); + incomingCount[edge.TargetNodeId] = incomingCount[edge.TargetNodeId] + 1; + } + + var orderedNodeIds = new List(nodes.Count); + var visited = new HashSet(StringComparer.Ordinal); + var onStack = new HashSet(StringComparer.Ordinal); + var backEdgeIds = new HashSet(StringComparer.Ordinal); + var preferredRoots = nodes + .Where(node => string.Equals(node.Kind, "Start", StringComparison.Ordinal)) + .Concat(nodes.Where(node => !string.Equals(node.Kind, "Start", StringComparison.Ordinal) && incomingCount[node.Id] == 0)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default) + .ToArray(); + + foreach (var root in preferredRoots) + { + Visit(root.Id); + } + + foreach (var node in nodes + .Where(node => !string.Equals(node.Kind, "End", StringComparison.Ordinal)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default)) + { + Visit(node.Id); + } + + foreach (var endNode in nodes + .Where(node => string.Equals(node.Kind, "End", StringComparison.Ordinal)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default)) + { + Visit(endNode.Id); + } + + var inputOrder = orderedNodeIds + .Select((nodeId, index) => new KeyValuePair(nodeId, index)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + + return (inputOrder, backEdgeIds); + + void Visit(string nodeId) + { + if (!visited.Add(nodeId)) + { + return; + } + + onStack.Add(nodeId); + orderedNodeIds.Add(nodeId); + foreach (var edge in outgoing[nodeId] + .OrderBy(edge => string.Equals(nodesById[edge.TargetNodeId].Kind, "End", StringComparison.Ordinal) ? 1 : 0) + .ThenBy(edge => originalOrder[edge.TargetNodeId], Comparer.Default)) + { + if (onStack.Contains(edge.TargetNodeId)) + { + backEdgeIds.Add(edge.Id); + } + + Visit(edge.TargetNodeId); + } + + onStack.Remove(nodeId); + } + } + + internal static Dictionary AssignLayersByInputOrder( + IReadOnlyCollection nodes, + IReadOnlyDictionary> outgoing, + IReadOnlyDictionary inputOrder, + IReadOnlySet backEdgeIds) + { + var layersByNodeId = nodes.ToDictionary(x => x.Id, _ => 0, StringComparer.Ordinal); + var orderedNodes = nodes + .OrderBy(node => inputOrder[node.Id], Comparer.Default) + .ToArray(); + + for (var iteration = 0; iteration < orderedNodes.Length; iteration++) + { + var changed = false; + foreach (var node in orderedNodes) + { + var sourceLayer = layersByNodeId[node.Id]; + foreach (var edge in outgoing[node.Id]) + { + if (backEdgeIds.Contains(edge.Id)) + { + continue; + } + + var candidateLayer = sourceLayer + 1; + if (candidateLayer <= layersByNodeId[edge.TargetNodeId]) + { + continue; + } + + layersByNodeId[edge.TargetNodeId] = candidateLayer; + changed = true; + } + } + + if (!changed) + { + break; + } + } + + for (var nodeIndex = orderedNodes.Length - 1; nodeIndex >= 0; nodeIndex--) + { + var node = orderedNodes[nodeIndex]; + var minSuccessorLayer = int.MaxValue; + foreach (var edge in outgoing[node.Id] + .Where(edge => !backEdgeIds.Contains(edge.Id))) + { + minSuccessorLayer = Math.Min(minSuccessorLayer, layersByNodeId[edge.TargetNodeId]); + } + + if (minSuccessorLayer != int.MaxValue) + { + var idealLayer = minSuccessorLayer - 1; + if (idealLayer > layersByNodeId[node.Id]) + { + layersByNodeId[node.Id] = idealLayer; + } + } + } + + return layersByNodeId; + } + + internal static DummyNodeResult InsertDummyNodes( + IReadOnlyCollection originalNodes, + IReadOnlyCollection originalEdges, + Dictionary layersByNodeId, + IReadOnlyDictionary inputOrder, + IReadOnlySet backEdgeIds) + { + var allNodes = new List(originalNodes); + var allEdges = new List(); + var augmentedLayers = new Dictionary(layersByNodeId, StringComparer.Ordinal); + var augmentedInputOrder = new Dictionary(inputOrder, StringComparer.Ordinal); + var dummyNodeIds = new HashSet(StringComparer.Ordinal); + var edgeDummyChains = new Dictionary>(StringComparer.Ordinal); + var nextInputOrder = inputOrder.Values.Max() + 1; + + foreach (var edge in originalEdges) + { + if (backEdgeIds.Contains(edge.Id)) + { + allEdges.Add(edge); + continue; + } + + var sourceLayer = layersByNodeId.GetValueOrDefault(edge.SourceNodeId, 0); + var targetLayer = layersByNodeId.GetValueOrDefault(edge.TargetNodeId, 0); + var span = targetLayer - sourceLayer; + + if (span <= 1) + { + allEdges.Add(edge); + continue; + } + + var chain = new List(); + var previousNodeId = edge.SourceNodeId; + + for (var layer = sourceLayer + 1; layer < targetLayer; layer++) + { + var dummyId = $"__dummy_{edge.Id}_{layer}"; + var dummyNode = new ElkNode + { + Id = dummyId, + Label = string.Empty, + Kind = "Dummy", + Width = 1, + Height = 1, + }; + + allNodes.Add(dummyNode); + augmentedLayers[dummyId] = layer; + augmentedInputOrder[dummyId] = inputOrder.GetValueOrDefault(edge.SourceNodeId, nextInputOrder); + dummyNodeIds.Add(dummyId); + chain.Add(dummyId); + + allEdges.Add(new ElkEdge + { + Id = $"{edge.Id}__seg_{layer}", + SourceNodeId = previousNodeId, + TargetNodeId = dummyId, + }); + + previousNodeId = dummyId; + } + + allEdges.Add(new ElkEdge + { + Id = $"{edge.Id}__seg_{targetLayer}", + SourceNodeId = previousNodeId, + TargetNodeId = edge.TargetNodeId, + }); + + edgeDummyChains[edge.Id] = chain; + } + + return new DummyNodeResult(allNodes, allEdges, augmentedLayers, augmentedInputOrder, dummyNodeIds, edgeDummyChains); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs new file mode 100644 index 000000000..2adf1a149 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs @@ -0,0 +1,186 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkLayoutHelpers +{ + internal static ElkPositionedNode CreatePositionedNode( + ElkNode node, + double x, + double y, + ElkLayoutDirection direction) + { + return new ElkPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + IconKey = node.IconKey, + SemanticType = node.SemanticType, + SemanticKey = node.SemanticKey, + Route = node.Route, + TaskType = node.TaskType, + ParentNodeId = node.ParentNodeId, + X = x, + Y = y, + Width = node.Width, + Height = node.Height, + Ports = PositionPorts(node, x, y, direction), + }; + } + + internal static IReadOnlyCollection PositionPorts( + ElkNode node, + double nodeX, + double nodeY, + ElkLayoutDirection direction) + { + if (node.Ports.Count == 0) + { + return []; + } + + var portsBySide = node.Ports + .GroupBy(x => NormalizeSide(x.Side, direction), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.OrdinalIgnoreCase); + + var positionedPorts = new List(node.Ports.Count); + foreach (var sideGroup in portsBySide) + { + var side = sideGroup.Key; + var ports = sideGroup.Value; + for (var index = 0; index < ports.Length; index++) + { + positionedPorts.Add(PositionPort(nodeX, nodeY, node.Width, node.Height, ports[index], side, index, ports.Length)); + } + } + + return positionedPorts; + } + + internal static ElkPositionedPort PositionPort( + double nodeX, + double nodeY, + double nodeWidth, + double nodeHeight, + ElkPort port, + string side, + int index, + int count) + { + var slot = (index + 1d) / (count + 1d); + var x = nodeX; + var y = nodeY; + + switch (side) + { + case "EAST": + x = nodeX + nodeWidth - (port.Width / 2d); + y = nodeY + (nodeHeight * slot) - (port.Height / 2d); + break; + case "WEST": + x = nodeX - (port.Width / 2d); + y = nodeY + (nodeHeight * slot) - (port.Height / 2d); + break; + case "NORTH": + x = nodeX + (nodeWidth * slot) - (port.Width / 2d); + y = nodeY - (port.Height / 2d); + break; + default: + x = nodeX + (nodeWidth * slot) - (port.Width / 2d); + y = nodeY + nodeHeight - (port.Height / 2d); + break; + } + + return new ElkPositionedPort + { + Id = port.Id, + Side = side, + X = x, + Y = y, + Width = port.Width, + Height = port.Height, + }; + } + + internal static Dictionary BuildLayerBoundariesByNodeId( + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layersByNodeId) + { + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .ToDictionary( + group => group.Key, + group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)); + }); + + return layersByNodeId + .Where(entry => boundariesByLayer.ContainsKey(entry.Value)) + .ToDictionary(entry => entry.Key, entry => boundariesByLayer[entry.Value], StringComparer.Ordinal); + } + + internal static LayerBoundary ResolveLayerBoundary( + string nodeId, + IReadOnlyDictionary layerBoundariesByNodeId, + ElkPositionedNode fallbackNode) + { + return layerBoundariesByNodeId.TryGetValue(nodeId, out var boundary) + ? boundary + : new LayerBoundary( + fallbackNode.X, + fallbackNode.X + fallbackNode.Width, + fallbackNode.Y, + fallbackNode.Y + fallbackNode.Height); + } + + internal static IReadOnlyCollection NormalizeBendPoints(params ElkPoint[] points) + { + if (points.Length == 0) + { + return []; + } + + var normalized = new List(points.Length); + foreach (var point in points) + { + if (normalized.Count > 0 + && Math.Abs(normalized[^1].X - point.X) <= 0.01d + && Math.Abs(normalized[^1].Y - point.Y) <= 0.01d) + { + continue; + } + + normalized.Add(point); + } + + return normalized; + } + + internal static double Clamp(double value, double minimum, double maximum) + { + return Math.Min(Math.Max(value, minimum), maximum); + } + + internal static string NormalizeSide(string? side, ElkLayoutDirection direction) + { + if (string.IsNullOrWhiteSpace(side)) + { + return direction == ElkLayoutDirection.LeftToRight ? "EAST" : "SOUTH"; + } + + return side.Trim().ToUpperInvariant() switch + { + "LEFT" => "WEST", + "RIGHT" => "EAST", + "TOP" => "NORTH", + "BOTTOM" => "SOUTH", + var normalized => normalized, + }; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs new file mode 100644 index 000000000..dc14fa927 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs @@ -0,0 +1,47 @@ +namespace StellaOps.ElkSharp; + +internal enum EdgeRouteMode +{ + Direct = 0, + BackwardOuter = 1, + SinkOuter = 2, + SinkOuterTop = 3, +} + +internal readonly record struct GraphBounds(double MinX, double MinY, double MaxX, double MaxY); + +internal readonly record struct LayerBoundary(double MinX, double MaxX, double MinY, double MaxY); + +internal readonly record struct EdgeChannel( + EdgeRouteMode RouteMode, + int BackwardLane, + int BackwardTargetIndex, + int BackwardTargetCount, + int ForwardIndex, + int ForwardCount, + int TargetIncomingIndex, + int TargetIncomingCount, + int SinkBandIndex, + int SinkBandCount, + double SharedOuterX, + double PreferredOuterY, + bool UseSourceCollector, + double PreferredDirectChannelX); + +internal readonly record struct DirectChannelCandidate( + string EdgeId, + string GapKey, + double GapMinX, + double GapMaxX, + int FamilyPriority, + double SourceCenterY, + double TargetCenterY, + double TargetX); + +internal sealed record DummyNodeResult( + List AllNodes, + List AllEdges, + Dictionary AugmentedLayers, + Dictionary AugmentedInputOrder, + HashSet DummyNodeIds, + Dictionary> EdgeDummyChains); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs new file mode 100644 index 000000000..375ac4275 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs @@ -0,0 +1,107 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkNodeOrdering +{ + internal static ElkNode[][] OptimizeLayerOrdering( + ElkNode[][] initialLayers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary inputOrder, + int iterationCount = 8) + { + if (initialLayers.Length <= 2) + { + return initialLayers; + } + + var layers = initialLayers + .Select(layer => layer.ToList()) + .ToArray(); + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++) + { + OrderLayer(layers, layerIndex, incomingNodeIds, inputOrder); + } + + for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--) + { + OrderLayer(layers, layerIndex, outgoingNodeIds, inputOrder); + } + } + + return layers + .Select(layer => layer.ToArray()) + .ToArray(); + } + + internal static void OrderLayer( + IReadOnlyList> layers, + int layerIndex, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary inputOrder) + { + var positions = BuildNodeOrderPositions(layers); + var currentLayer = layers[layerIndex]; + currentLayer.Sort((left, right) => + { + var leftRank = ResolveOrderingRank(left.Id, adjacentNodeIds, positions); + var rightRank = ResolveOrderingRank(right.Id, adjacentNodeIds, positions); + var comparison = leftRank.CompareTo(rightRank); + if (comparison != 0) + { + return comparison; + } + + comparison = positions[left.Id].CompareTo(positions[right.Id]); + if (comparison != 0) + { + return comparison; + } + + return inputOrder[left.Id].CompareTo(inputOrder[right.Id]); + }); + } + + internal static Dictionary BuildNodeOrderPositions(IReadOnlyList> layers) + { + var positions = new Dictionary(StringComparer.Ordinal); + foreach (var layer in layers) + { + for (var index = 0; index < layer.Count; index++) + { + positions[layer[index].Id] = index; + } + } + + return positions; + } + + internal static double ResolveOrderingRank( + string nodeId, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary positions) + { + if (!adjacentNodeIds.TryGetValue(nodeId, out var neighbors) || neighbors.Count == 0) + { + return double.PositiveInfinity; + } + + var ordered = neighbors + .Where(positions.ContainsKey) + .Select(neighborId => (double)positions[neighborId]) + .OrderBy(value => value) + .ToArray(); + + if (ordered.Length == 0) + { + return double.PositiveInfinity; + } + + var middle = ordered.Length / 2; + return ordered.Length % 2 == 1 + ? ordered[middle] + : (ordered[middle - 1] + ordered[middle]) / 2d; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs new file mode 100644 index 000000000..2a3f04543 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs @@ -0,0 +1,264 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkNodePlacement +{ + internal static int ResolveOrderingIterationCount( + ElkLayoutOptions options, + int edgeCount, + int nodeCount) + { + if (options.OrderingIterations is int explicitIterations) + { + return Math.Max(2, explicitIterations); + } + + var baseline = Math.Max(6, Math.Max(edgeCount / 4, nodeCount / 3)); + return options.Effort switch + { + ElkLayoutEffort.Draft => Math.Min(8, baseline), + ElkLayoutEffort.Balanced => Math.Min(14, Math.Max(8, baseline)), + _ => Math.Min(24, Math.Max(12, baseline + 4)), + }; + } + + internal static int ResolvePlacementIterationCount( + ElkLayoutOptions options, + int nodeCount, + int layerCount) + { + if (options.PlacementIterations is int explicitIterations) + { + return Math.Max(1, explicitIterations); + } + + var baseline = Math.Max(2, Math.Max(nodeCount / 8, layerCount / 2)); + return options.Effort switch + { + ElkLayoutEffort.Draft => Math.Min(3, baseline), + ElkLayoutEffort.Balanced => Math.Min(6, Math.Max(3, baseline)), + _ => Math.Min(10, Math.Max(5, baseline + 2)), + }; + } + + internal static void RefineHorizontalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredY = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ElkNodePlacementPreferredCenter.ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: true); + desiredY[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Height / 2d) + : positionedNodes[node.Id].Y; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + nodeSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + current.X, + desiredY[nodeIndex], + direction); + } + } + } + } + + internal static void RefineVerticalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredX = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ElkNodePlacementPreferredCenter.ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: false); + desiredX[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Width / 2d) + : positionedNodes[node.Id].X; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + nodeSpacing; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + desiredX[nodeIndex], + current.Y, + direction); + } + } + } + } + + internal static void SnapOriginalPrimaryAxes( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + var nodeDesiredPairs = new (ElkNode Node, double Desired)[actualNodes.Length]; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var positioned = positionedNodes[actualNodes[nodeIndex].Id]; + var preferredCenter = ElkNodePlacementPreferredCenter.ResolveOriginalPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + nodeDesiredPairs[nodeIndex] = (actualNodes[nodeIndex], preferredCenter.HasValue + ? preferredCenter.Value - ((direction == ElkLayoutDirection.LeftToRight ? positioned.Height : positioned.Width) / 2d) + : (direction == ElkLayoutDirection.LeftToRight ? positioned.Y : positioned.X)); + } + + Array.Sort(nodeDesiredPairs, (a, b) => a.Desired.CompareTo(b.Desired)); + var sortedNodes = nodeDesiredPairs.Select(p => p.Node).ToArray(); + var desiredCoordinates = nodeDesiredPairs.Select(p => p.Desired).ToArray(); + + EnforceLinearSpacing( + sortedNodes, + desiredCoordinates, + nodeSpacing, + horizontal: direction == ElkLayoutDirection.LeftToRight); + + for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++) + { + var current = positionedNodes[sortedNodes[nodeIndex].Id]; + positionedNodes[sortedNodes[nodeIndex].Id] = direction == ElkLayoutDirection.LeftToRight + ? ElkLayoutHelpers.CreatePositionedNode(sortedNodes[nodeIndex], current.X, desiredCoordinates[nodeIndex], direction) + : ElkLayoutHelpers.CreatePositionedNode(sortedNodes[nodeIndex], desiredCoordinates[nodeIndex], current.Y, direction); + } + } + } + } + + internal static void EnforceLinearSpacing( + IReadOnlyList layer, + double[] desiredCoordinates, + double spacing, + bool horizontal) + { + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + } + + for (var index = layer.Count - 2; index >= 0; index--) + { + var extent = horizontal ? layer[index].Height : layer[index].Width; + desiredCoordinates[index] = Math.Min( + desiredCoordinates[index], + desiredCoordinates[index + 1] - extent - spacing); + } + + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementAlignment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementAlignment.cs new file mode 100644 index 000000000..25f3d9212 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementAlignment.cs @@ -0,0 +1,267 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkNodePlacementAlignment +{ + internal static void PropagateSuccessorPositionBackward( + Dictionary positionedNodes, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + ElkLayoutDirection direction) + { + var horizontal = direction == ElkLayoutDirection.LeftToRight; + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!originalNodesById.ContainsKey(nodeId)) + { + continue; + } + + var current = positionedNodes[nodeId]; + if (!string.Equals(current.Kind, "Fork", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!outgoingNodeIds.TryGetValue(nodeId, out var forkOutgoing) || forkOutgoing.Count < 2) + { + continue; + } + + var joinSuccessor = forkOutgoing + .Select(id => positionedNodes.GetValueOrDefault(id)) + .FirstOrDefault(n => n is not null + && string.Equals(n.Kind, "Join", StringComparison.OrdinalIgnoreCase)); + if (joinSuccessor is null) + { + continue; + } + + var joinCenter = horizontal + ? joinSuccessor.Y + (joinSuccessor.Height / 2d) + : joinSuccessor.X + (joinSuccessor.Width / 2d); + var forkCenter = horizontal + ? current.Y + (current.Height / 2d) + : current.X + (current.Width / 2d); + if (Math.Abs(joinCenter - forkCenter) < 20d) + { + continue; + } + + var chainNodeIds = new List { nodeId }; + var walkId = nodeId; + for (var step = 0; step < 20; step++) + { + var predecessor = positionedNodes.Keys + .Where(id => originalNodesById.ContainsKey(id) + && outgoingNodeIds.TryGetValue(id, out var outs) && outs.Count == 1 && outs[0] == walkId) + .FirstOrDefault(); + if (predecessor is null) + { + break; + } + + chainNodeIds.Add(predecessor); + walkId = predecessor; + } + + foreach (var chainId in chainNodeIds) + { + var pos = positionedNodes[chainId]; + var orig = originalNodesById[chainId]; + if (horizontal) + { + positionedNodes[chainId] = ElkLayoutHelpers.CreatePositionedNode( + orig, pos.X, joinCenter - (pos.Height / 2d), direction); + } + else + { + positionedNodes[chainId] = ElkLayoutHelpers.CreatePositionedNode( + orig, joinCenter - (pos.Width / 2d), pos.Y, direction); + } + } + } + } + + internal static void CenterMultiIncomingNodes( + Dictionary positionedNodes, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary originalNodesById, + ElkLayoutDirection direction) + { + var horizontal = direction == ElkLayoutDirection.LeftToRight; + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!originalNodesById.TryGetValue(nodeId, out var originalNode)) + { + continue; + } + + var current = positionedNodes[nodeId]; + if (!string.Equals(current.Kind, "Join", StringComparison.OrdinalIgnoreCase) + && !string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count < 2) + { + continue; + } + + var centers = incomingIds + .Select(id => positionedNodes.GetValueOrDefault(id)) + .Where(n => n is not null) + .Select(n => horizontal ? n!.Y + (n.Height / 2d) : n!.X + (n.Width / 2d)) + .OrderBy(c => c) + .ToArray(); + + if (centers.Length < 2) + { + continue; + } + + double medianCenter; + if (string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + medianCenter = (centers[0] + centers[^1]) / 2d; + } + else + { + var mid = centers.Length / 2; + medianCenter = centers.Length % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + } + + if (horizontal) + { + var desiredY = medianCenter - (current.Height / 2d); + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(originalNode, current.X, desiredY, direction); + } + else + { + var desiredX = medianCenter - (current.Width / 2d); + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(originalNode, desiredX, current.Y, direction); + } + } + } + + internal static void CompactTowardIncomingFlow( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + var previousBottom = double.NegativeInfinity; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var current = positionedNodes[actualNodes[nodeIndex].Id]; + var targetY = current.Y; + if (nodeIndex > 0) + { + targetY = Math.Max(targetY, previousBottom + nodeSpacing); + } + + if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) + { + var preferredCenter = ElkNodePlacementPreferredCenter.ResolveIncomingPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + positionedNodes, + horizontal: true); + if (preferredCenter.HasValue) + { + targetY = Math.Max( + nodeIndex > 0 ? previousBottom + nodeSpacing : double.NegativeInfinity, + Math.Min(current.Y, preferredCenter.Value - (current.Height / 2d))); + } + } + + positionedNodes[actualNodes[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + actualNodes[nodeIndex], + current.X, + targetY, + direction); + previousBottom = targetY + current.Height; + } + + continue; + } + + var previousRight = double.NegativeInfinity; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var current = positionedNodes[actualNodes[nodeIndex].Id]; + var targetX = current.X; + if (nodeIndex > 0) + { + targetX = Math.Max(targetX, previousRight + nodeSpacing); + } + + if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) + { + var preferredCenter = ElkNodePlacementPreferredCenter.ResolveIncomingPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + positionedNodes, + horizontal: false); + if (preferredCenter.HasValue) + { + targetX = Math.Max( + nodeIndex > 0 ? previousRight + nodeSpacing : double.NegativeInfinity, + Math.Min(current.X, preferredCenter.Value - (current.Width / 2d))); + } + } + + positionedNodes[actualNodes[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + actualNodes[nodeIndex], + targetX, + current.Y, + direction); + previousRight = targetX + current.Width; + } + } + } + } + + internal static bool ShouldCompactTowardIncoming( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary positionedNodes) + { + if (!positionedNodes.TryGetValue(nodeId, out var currentNode) + || string.Equals(currentNode.Kind, "Start", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) + { + return false; + } + + return incomingIds + .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) + .Where(incomingNode => incomingNode is not null) + .All(incomingNode => !string.Equals(incomingNode!.Kind, "Fork", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementPreferredCenter.cs b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementPreferredCenter.cs new file mode 100644 index 000000000..0ee317388 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementPreferredCenter.cs @@ -0,0 +1,260 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkNodePlacementPreferredCenter +{ + internal static void AlignDummyNodesToFlow( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 2; iteration++) + { + foreach (var layer in layers) + { + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + if (!dummyNodeIds.Contains(node.Id)) + { + continue; + } + + var current = positionedNodes[node.Id]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + if (!preferredCenter.HasValue) + { + continue; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + var minY = nodeIndex == 0 + ? double.NegativeInfinity + : positionedNodes[layer[nodeIndex - 1].Id].Y + positionedNodes[layer[nodeIndex - 1].Id].Height + 1d; + var maxY = nodeIndex == layer.Length - 1 + ? double.PositiveInfinity + : positionedNodes[layer[nodeIndex + 1].Id].Y - current.Height - 1d; + var desiredY = ElkLayoutHelpers.Clamp(preferredCenter.Value - (current.Height / 2d), minY, maxY); + positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode(nodesById[node.Id], current.X, desiredY, direction); + continue; + } + + var minX = nodeIndex == 0 + ? double.NegativeInfinity + : positionedNodes[layer[nodeIndex - 1].Id].X + positionedNodes[layer[nodeIndex - 1].Id].Width + 1d; + var maxX = nodeIndex == layer.Length - 1 + ? double.PositiveInfinity + : positionedNodes[layer[nodeIndex + 1].Id].X - current.Width - 1d; + var desiredX = ElkLayoutHelpers.Clamp(preferredCenter.Value - (current.Width / 2d), minX, maxX); + positionedNodes[node.Id] = ElkLayoutHelpers.CreatePositionedNode(nodesById[node.Id], desiredX, current.Y, direction); + } + } + } + } + + internal static double? ResolveIncomingPreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) + { + return null; + } + + var centers = incomingIds + .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) + .Where(incomingNode => incomingNode is not null) + .Select(incomingNode => horizontal + ? incomingNode!.Y + (incomingNode.Height / 2d) + : incomingNode!.X + (incomingNode.Width / 2d)) + .OrderBy(center => center) + .ToArray(); + if (centers.Length == 0) + { + return null; + } + + var mid = centers.Length / 2; + return centers.Length % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + } + + internal static double? ResolveOriginalPreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (!positionedNodes.TryGetValue(nodeId, out var currentNode)) + { + return null; + } + + incomingNodeIds.TryGetValue(nodeId, out var incomingIds); + if (incomingIds is not null + && string.Equals(currentNode.Kind, "Join", StringComparison.OrdinalIgnoreCase) + && incomingIds.Count > 0) + { + var branchCenters = incomingIds + .Select(adjacentNodeId => positionedNodes.GetValueOrDefault(adjacentNodeId)) + .Where(adjacentNode => adjacentNode is not null) + .Select(adjacentNode => horizontal + ? adjacentNode!.Y + (adjacentNode.Height / 2d) + : adjacentNode!.X + (adjacentNode.Width / 2d)) + .OrderBy(center => center) + .ToArray(); + if (branchCenters.Length > 0) + { + var mid = branchCenters.Length / 2; + return branchCenters.Length % 2 == 1 + ? branchCenters[mid] + : (branchCenters[mid - 1] + branchCenters[mid]) / 2d; + } + } + + if (incomingIds is not null + && incomingIds.Count == 1 + && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) + && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) + && predecessorOutgoing.Count == 1) + { + return horizontal + ? linearPredecessor.Y + (linearPredecessor.Height / 2d) + : linearPredecessor.X + (linearPredecessor.Width / 2d); + } + + if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) + && outgoingIds.Count == 1 + && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) + && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) + && successorIncoming.Count == 1) + { + return horizontal + ? linearSuccessor.Y + (linearSuccessor.Height / 2d) + : linearSuccessor.X + (linearSuccessor.Width / 2d); + } + + var allCenters = new List(); + if (incomingIds is not null) + { + foreach (var adjacentNodeId in incomingIds) + { + if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + allCenters.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + } + + if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) + { + foreach (var adjacentNodeId in outgoingIds) + { + if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + allCenters.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + } + + if (allCenters.Count > 0) + { + allCenters.Sort(); + var mid = allCenters.Count / 2; + return allCenters.Count % 2 == 1 + ? allCenters[mid] + : (allCenters[mid - 1] + allCenters[mid]) / 2d; + } + + return null; + } + + internal static double? ResolvePreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (incomingNodeIds.TryGetValue(nodeId, out var incomingIds) + && incomingIds.Count == 1 + && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) + && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) + && predecessorOutgoing.Count == 1) + { + return horizontal + ? linearPredecessor.Y + (linearPredecessor.Height / 2d) + : linearPredecessor.X + (linearPredecessor.Width / 2d); + } + + if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) + && outgoingIds.Count == 1 + && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) + && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) + && successorIncoming.Count == 1) + { + return horizontal + ? linearSuccessor.Y + (linearSuccessor.Height / 2d) + : linearSuccessor.X + (linearSuccessor.Width / 2d); + } + + var coordinates = new List(); + if (incomingNodeIds.TryGetValue(nodeId, out incomingIds)) + { + foreach (var adjacentNodeId in incomingIds) + { + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + coordinates.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + + if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) + { + foreach (var adjacentNodeId in outgoingIds) + { + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + coordinates.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + + if (coordinates.Count == 0) + { + return null; + } + + coordinates.Sort(); + var mid = coordinates.Count / 2; + return coordinates.Count % 2 == 1 + ? coordinates[mid] + : (coordinates[mid - 1] + coordinates[mid]) / 2d; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs new file mode 100644 index 000000000..1cba9cfaf --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs @@ -0,0 +1,191 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkShapeBoundaries +{ + internal static ElkPoint ProjectOntoShapeBoundary(ElkPositionedNode node, ElkPoint toward) + { + if (node.Kind is "Decision" or "Fork" or "Join") + { + var cx = node.X + node.Width / 2d; + var cy = node.Y + node.Height / 2d; + var dx = toward.X - cx; + var dy = toward.Y - cy; + return ResolveGatewayBoundaryPoint(node, toward, dx, dy); + } + + return ProjectOntoRectBoundary(node, toward); + } + + internal static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward) + { + var cx = node.X + node.Width / 2d; + var cy = node.Y + node.Height / 2d; + var hw = node.Width / 2d; + var hh = node.Height / 2d; + var dx = toward.X - cx; + var dy = toward.Y - cy; + + if (Math.Abs(dx) < 0.1d && Math.Abs(dy) < 0.1d) + { + return new ElkPoint { X = cx + hw, Y = cy }; + } + + var tMin = double.MaxValue; + if (dx > 0.1d) { var t = hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } + if (dx < -0.1d) { var t = -hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } + if (dy > 0.1d) { var t = hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } + if (dy < -0.1d) { var t = -hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } + + return tMin < double.MaxValue + ? new ElkPoint { X = cx + dx * tMin, Y = cy + dy * tMin } + : new ElkPoint { X = cx + hw, Y = cy }; + } + + internal static ElkPoint IntersectDiamondBoundary( + double centerX, + double centerY, + double halfWidth, + double halfHeight, + double deltaX, + double deltaY) + { + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + return new ElkPoint + { + X = centerX, + Y = centerY, + }; + } + + var scale = 1d / ((Math.Abs(deltaX) / Math.Max(halfWidth, 0.001d)) + (Math.Abs(deltaY) / Math.Max(halfHeight, 0.001d))); + return new ElkPoint + { + X = centerX + (deltaX * scale), + Y = centerY + (deltaY * scale), + }; + } + + internal static ElkPoint ResolveGatewayBoundaryPoint( + ElkPositionedNode node, + ElkPoint candidate, + double deltaX, + double deltaY) + { + if (node.Kind is not ("Decision" or "Fork" or "Join")) + { + return candidate; + } + + var centerX = node.X + (node.Width / 2d); + var centerY = node.Y + (node.Height / 2d); + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + deltaX = candidate.X - centerX; + deltaY = candidate.Y - centerY; + } + + if (node.Kind == "Decision") + { + return IntersectDiamondBoundary(centerX, centerY, node.Width / 2d, node.Height / 2d, deltaX, deltaY); + } + + return IntersectPolygonBoundary( + centerX, + centerY, + deltaX, + deltaY, + BuildForkBoundaryPoints(node)); + } + + internal static IReadOnlyList BuildForkBoundaryPoints(ElkPositionedNode node) + { + var cornerInset = Math.Min(22d, Math.Max(6d, node.Width * 0.125d)); + var verticalInset = Math.Min(8d, Math.Max(4d, node.Height * 0.065d)); + return + [ + new ElkPoint { X = node.X + cornerInset, Y = node.Y + verticalInset }, + new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + verticalInset }, + new ElkPoint { X = node.X + node.Width, Y = node.Y + (node.Height / 2d) }, + new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + node.Height - verticalInset }, + new ElkPoint { X = node.X + cornerInset, Y = node.Y + node.Height - verticalInset }, + new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) }, + ]; + } + + internal static ElkPoint IntersectPolygonBoundary( + double originX, + double originY, + double deltaX, + double deltaY, + IReadOnlyList polygon) + { + var bestScale = double.PositiveInfinity; + ElkPoint? bestPoint = null; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) + { + continue; + } + + if (scale < bestScale) + { + bestScale = scale; + bestPoint = point; + } + } + + return bestPoint ?? new ElkPoint + { + X = originX + deltaX, + Y = originY + deltaY, + }; + } + + internal static bool TryIntersectRayWithSegment( + double originX, + double originY, + double deltaX, + double deltaY, + ElkPoint segmentStart, + ElkPoint segmentEnd, + out double scale, + out ElkPoint point) + { + scale = double.PositiveInfinity; + point = default!; + + var segmentDeltaX = segmentEnd.X - segmentStart.X; + var segmentDeltaY = segmentEnd.Y - segmentStart.Y; + var denominator = Cross(deltaX, deltaY, segmentDeltaX, segmentDeltaY); + if (Math.Abs(denominator) <= 0.001d) + { + return false; + } + + var relativeX = segmentStart.X - originX; + var relativeY = segmentStart.Y - originY; + var rayScale = Cross(relativeX, relativeY, segmentDeltaX, segmentDeltaY) / denominator; + var segmentScale = Cross(relativeX, relativeY, deltaX, deltaY) / denominator; + if (rayScale < 0d || segmentScale < 0d || segmentScale > 1d) + { + return false; + } + + scale = rayScale; + point = new ElkPoint + { + X = originX + (deltaX * rayScale), + Y = originY + (deltaY * rayScale), + }; + return true; + } + + internal static double Cross(double ax, double ay, double bx, double by) + { + return (ax * by) - (ay * bx); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.ARCHIVED.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.ARCHIVED.cs new file mode 100644 index 000000000..d8f526c64 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.ARCHIVED.cs @@ -0,0 +1,4896 @@ +namespace StellaOps.ElkSharp; + +public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine +{ + public Task LayoutAsync( + ElkGraph graph, + ElkLayoutOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + cancellationToken.ThrowIfCancellationRequested(); + + options ??= new ElkLayoutOptions(); + ValidateGraph(graph); + + var nodesById = graph.Nodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var (inputOrder, backEdgeIds) = BuildTraversalInputOrder(graph.Nodes, graph.Edges, nodesById); + + var outgoing = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + var incomingNodeIds = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + var outgoingNodeIds = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + + foreach (var edge in graph.Edges) + { + outgoing[edge.SourceNodeId].Add(edge); + incomingNodeIds[edge.TargetNodeId].Add(edge.SourceNodeId); + outgoingNodeIds[edge.SourceNodeId].Add(edge.TargetNodeId); + } + + var layersByNodeId = AssignLayersByInputOrder(graph.Nodes, outgoing, inputOrder, backEdgeIds); + + var dummyResult = InsertDummyNodes(graph.Nodes, graph.Edges, layersByNodeId, inputOrder, backEdgeIds); + var allNodes = dummyResult.AllNodes; + var allEdges = dummyResult.AllEdges; + var augmentedNodesById = allNodes.ToDictionary(x => x.Id, StringComparer.Ordinal); + var augmentedInputOrder = dummyResult.AugmentedInputOrder; + var augmentedIncoming = allNodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + var augmentedOutgoing = allNodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); + foreach (var edge in allEdges) + { + augmentedIncoming[edge.TargetNodeId].Add(edge.SourceNodeId); + augmentedOutgoing[edge.SourceNodeId].Add(edge.TargetNodeId); + } + + var orderingIterations = ResolveOrderingIterationCount(options, allEdges.Count, layersByNodeId.Count); + var layers = allNodes + .GroupBy(x => dummyResult.AugmentedLayers[x.Id]) + .OrderBy(x => x.Key) + .Select(x => x.OrderBy(node => augmentedInputOrder[node.Id]).ToArray()) + .ToArray(); + layers = OptimizeLayerOrdering(layers, augmentedIncoming, augmentedOutgoing, augmentedInputOrder, orderingIterations); + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var realNodes = layers[layerIndex].Where(n => !dummyResult.DummyNodeIds.Contains(n.Id)).ToArray(); + var dummyNodes = layers[layerIndex].Where(n => dummyResult.DummyNodeIds.Contains(n.Id)).ToArray(); + layers[layerIndex] = realNodes.Concat(dummyNodes).ToArray(); + } + + var placementIterations = ResolvePlacementIterationCount(options, allNodes.Count, layers.Length); + + var positionedNodes = new Dictionary(StringComparer.Ordinal); + var globalNodeHeight = graph.Nodes.Max(x => x.Height); + var globalNodeWidth = graph.Nodes.Max(x => x.Width); + var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, allEdges.Count - 15) * 0.02d)); + var adaptiveNodeSpacing = options.NodeSpacing * edgeDensityFactor; + var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)); + if (options.Direction == ElkLayoutDirection.LeftToRight) + { + var layerXPositions = new double[layers.Length]; + var currentX = 0d; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + layerXPositions[layerIndex] = currentX; + currentX += layers[layerIndex].Max(x => x.Width) + adaptiveLayerSpacing; + } + + var slotHeight = globalNodeHeight; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var layer = layers[layerIndex]; + var desiredY = new double[layer.Length]; + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var centers = new List(); + foreach (var srcId in augmentedIncoming[node.Id]) + { + if (positionedNodes.TryGetValue(srcId, out var srcPos)) + { + centers.Add(srcPos.Y + (srcPos.Height / 2d)); + } + } + + if (centers.Count > 0) + { + centers.Sort(); + var mid = centers.Count / 2; + var median = centers.Count % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + desiredY[nodeIndex] = median - (node.Height / 2d); + } + else + { + desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing); + } + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); + var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); + var pairSpacing = (prevIsDummy && currIsDummy) ? 2d + : (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) + : adaptiveNodeSpacing; + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + layer[nodeIndex], layerXPositions[layerIndex], desiredY[nodeIndex], options.Direction); + } + } + + var minNodeY = positionedNodes.Values.Min(n => n.Y); + if (minNodeY < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); + } + } + + RefineHorizontalPlacement( + positionedNodes, + layers, + incomingNodeIds, + outgoingNodeIds, + augmentedNodesById, + options.NodeSpacing, + placementIterations, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + CompactTowardIncomingFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + AlignDummyNodesToFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + augmentedIncoming, + augmentedOutgoing, + augmentedNodesById, + options.Direction); + + CenterMultiIncomingNodes( + positionedNodes, + incomingNodeIds, + nodesById, + options.Direction); + + PropagateSuccessorPositionBackward( + positionedNodes, + outgoingNodeIds, + nodesById, + options.Direction); + + minNodeY = positionedNodes.Values.Min(n => n.Y); + if (minNodeY < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); + } + } + } + else + { + var layerYPositions = new double[layers.Length]; + var currentY = 0d; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + layerYPositions[layerIndex] = currentY; + currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing; + } + + var slotWidth = globalNodeWidth; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var layer = layers[layerIndex]; + var desiredX = new double[layer.Length]; + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var centers = new List(); + foreach (var srcId in augmentedIncoming[node.Id]) + { + if (positionedNodes.TryGetValue(srcId, out var srcPos)) + { + centers.Add(srcPos.X + (srcPos.Width / 2d)); + } + } + + if (centers.Count > 0) + { + centers.Sort(); + var mid = centers.Count / 2; + var median = centers.Count % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + desiredX[nodeIndex] = median - (node.Width / 2d); + } + else + { + desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing); + } + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); + var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); + var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d + : (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) + : adaptiveNodeSpacing; + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + layer[nodeIndex], desiredX[nodeIndex], layerYPositions[layerIndex], options.Direction); + } + } + + var minNodeX = positionedNodes.Values.Min(n => n.X); + if (minNodeX < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); + } + } + + RefineVerticalPlacement( + positionedNodes, + layers, + incomingNodeIds, + outgoingNodeIds, + augmentedNodesById, + options.NodeSpacing, + placementIterations, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + CompactTowardIncomingFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + SnapOriginalPrimaryAxes( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + incomingNodeIds, + outgoingNodeIds, + nodesById, + options.NodeSpacing, + options.Direction); + + AlignDummyNodesToFlow( + positionedNodes, + layers, + dummyResult.DummyNodeIds, + augmentedIncoming, + augmentedOutgoing, + augmentedNodesById, + options.Direction); + + CenterMultiIncomingNodes( + positionedNodes, + incomingNodeIds, + nodesById, + options.Direction); + + PropagateSuccessorPositionBackward( + positionedNodes, + outgoingNodeIds, + nodesById, + options.Direction); + + minNodeX = positionedNodes.Values.Min(n => n.X); + if (minNodeX < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode( + augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); + } + } + } + + var graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + var layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + var edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + var reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + var routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var routed) + ? routed + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + for (var gutterPass = 0; gutterPass < 3; gutterPass++) + { + if (!ExpandVerticalCorridorGutters( + positionedNodes, + routedEdges, + dummyResult.AugmentedLayers, + augmentedNodesById, + options.LayerSpacing, + options.Direction)) + { + break; + } + + graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + } + + for (var compactPass = 0; compactPass < 2; compactPass++) + { + if (!CompactSparseVerticalCorridorGutters( + positionedNodes, + routedEdges, + dummyResult.AugmentedLayers, + augmentedNodesById, + options.LayerSpacing, + options.Direction)) + { + break; + } + + graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + + if (!ExpandVerticalCorridorGutters( + positionedNodes, + routedEdges, + dummyResult.AugmentedLayers, + augmentedNodesById, + options.LayerSpacing, + options.Direction)) + { + continue; + } + + graphBounds = ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .ToArray(); + } + + var finalNodes = positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)) + .OrderBy(x => inputOrder.GetValueOrDefault(x.Id, int.MaxValue)) + .ToArray(); + + routedEdges = AvoidNodeCrossings(routedEdges, finalNodes, options.Direction); + routedEdges = DistributeOverlappingPorts(routedEdges, finalNodes, options.Direction); + routedEdges = ReassignEdgesToCorrectSide(routedEdges, finalNodes); + routedEdges = SimplifyEdgePaths(routedEdges, finalNodes); + routedEdges = SeparateOverlappingEdgeSegments(routedEdges); + routedEdges = TightenOuterCorridors(routedEdges, finalNodes); + routedEdges = SnapAnchorsToNodeBoundary(routedEdges, finalNodes); + routedEdges = EliminateDiagonalSegments(routedEdges); + + return Task.FromResult(new ElkLayoutResult + { + GraphId = graph.Id, + Nodes = finalNodes, + Edges = routedEdges, + }); + } + + private static void ValidateGraph(ElkGraph graph) + { + if (graph.Nodes.Count == 0) + { + throw new InvalidOperationException("ElkSharp requires at least one node."); + } + + var duplicateNodeId = graph.Nodes + .GroupBy(x => x.Id, StringComparer.Ordinal) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateNodeId is not null) + { + throw new InvalidOperationException($"ElkSharp requires unique node ids. Duplicate '{duplicateNodeId.Key}' was found."); + } + + if (graph.Nodes.Any(x => !string.IsNullOrWhiteSpace(x.ParentNodeId))) + { + throw new NotSupportedException("ElkSharp currently supports flat graphs only. Compound nodes are not implemented in this spike."); + } + + var nodeIds = graph.Nodes.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); + foreach (var edge in graph.Edges) + { + if (!nodeIds.Contains(edge.SourceNodeId) || !nodeIds.Contains(edge.TargetNodeId)) + { + throw new InvalidOperationException($"Edge '{edge.Id}' references an unknown node."); + } + } + } + + private static ElkPositionedNode CreatePositionedNode( + ElkNode node, + double x, + double y, + ElkLayoutDirection direction) + { + return new ElkPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + IconKey = node.IconKey, + SemanticType = node.SemanticType, + SemanticKey = node.SemanticKey, + Route = node.Route, + TaskType = node.TaskType, + ParentNodeId = node.ParentNodeId, + X = x, + Y = y, + Width = node.Width, + Height = node.Height, + Ports = PositionPorts(node, x, y, direction), + }; + } + + private static IReadOnlyCollection PositionPorts( + ElkNode node, + double nodeX, + double nodeY, + ElkLayoutDirection direction) + { + if (node.Ports.Count == 0) + { + return []; + } + + var portsBySide = node.Ports + .GroupBy(x => NormalizeSide(x.Side, direction), StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.OrdinalIgnoreCase); + + var positionedPorts = new List(node.Ports.Count); + foreach (var sideGroup in portsBySide) + { + var side = sideGroup.Key; + var ports = sideGroup.Value; + for (var index = 0; index < ports.Length; index++) + { + positionedPorts.Add(PositionPort(nodeX, nodeY, node.Width, node.Height, ports[index], side, index, ports.Length)); + } + } + + return positionedPorts; + } + + private static ElkPositionedPort PositionPort( + double nodeX, + double nodeY, + double nodeWidth, + double nodeHeight, + ElkPort port, + string side, + int index, + int count) + { + var slot = (index + 1d) / (count + 1d); + var x = nodeX; + var y = nodeY; + + switch (side) + { + case "EAST": + x = nodeX + nodeWidth - (port.Width / 2d); + y = nodeY + (nodeHeight * slot) - (port.Height / 2d); + break; + case "WEST": + x = nodeX - (port.Width / 2d); + y = nodeY + (nodeHeight * slot) - (port.Height / 2d); + break; + case "NORTH": + x = nodeX + (nodeWidth * slot) - (port.Width / 2d); + y = nodeY - (port.Height / 2d); + break; + default: + x = nodeX + (nodeWidth * slot) - (port.Width / 2d); + y = nodeY + nodeHeight - (port.Height / 2d); + break; + } + + return new ElkPositionedPort + { + Id = port.Id, + Side = side, + X = x, + Y = y, + Width = port.Width, + Height = port.Height, + }; + } + + private static ElkRoutedEdge RouteEdge( + ElkEdge edge, + IReadOnlyDictionary nodesById, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + + var (sourceSide, targetSide) = ResolveRouteSides(sourceNode, targetNode, direction); + var sourcePoint = ResolveAnchorPoint(sourceNode, targetNode, edge.SourcePortId, direction, sourceSide); + var targetPoint = ResolveAnchorPoint(targetNode, sourceNode, edge.TargetPortId, direction, targetSide); + + if (string.IsNullOrWhiteSpace(edge.SourcePortId) + && string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.RouteMode == EdgeRouteMode.Direct) + { + (sourcePoint, targetPoint) = ResolveStraightChainAnchors( + sourceNode, + targetNode, + sourcePoint, + targetPoint, + sourceSide, + targetSide, + channel, + direction); + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.TargetIncomingCount > 1 + && direction == ElkLayoutDirection.LeftToRight + && targetPoint.X >= sourcePoint.X) + { + var insetY = Math.Min(16d, (targetNode.Height - 12d) / Math.Max(1, channel.TargetIncomingCount)); + var totalInset = (channel.TargetIncomingCount - 1) * insetY; + var adjustedY = (targetNode.Y + (targetNode.Height / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetY); + adjustedY = Clamp(adjustedY, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d); + targetPoint = new ElkPoint { X = targetPoint.X, Y = adjustedY }; + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.TargetIncomingCount > 1 + && direction == ElkLayoutDirection.TopToBottom + && targetPoint.Y >= sourcePoint.Y) + { + var insetX = Math.Min(16d, (targetNode.Width - 12d) / Math.Max(1, channel.TargetIncomingCount)); + var totalInset = (channel.TargetIncomingCount - 1) * insetX; + var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetX); + adjustedX = Clamp(adjustedX, targetNode.X + 6d, targetNode.X + targetNode.Width - 6d); + targetPoint = new ElkPoint { X = adjustedX, Y = targetPoint.Y }; + } + + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && channel.BackwardTargetCount > 1 + && targetPoint.X < sourcePoint.X + && direction == ElkLayoutDirection.LeftToRight) + { + var spread = Math.Min(24d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); + var totalSpread = (channel.BackwardTargetCount - 1) * spread; + var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); + adjustedX = Clamp(adjustedX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); + targetPoint = new ElkPoint { X = adjustedX, Y = targetNode.Y }; + } + + var bendPoints = direction == ElkLayoutDirection.LeftToRight + ? BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId) + : BuildVerticalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId); + + return new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = sourcePoint, + EndPoint = targetPoint, + BendPoints = bendPoints, + }, + ], + }; + } + + private static ElkPoint ResolveAnchorPoint( + ElkPositionedNode node, + ElkPositionedNode otherNode, + string? portId, + ElkLayoutDirection direction, + string? forcedSide = null) + { + if (!string.IsNullOrWhiteSpace(portId)) + { + var port = node.Ports.FirstOrDefault(x => string.Equals(x.Id, portId, StringComparison.Ordinal)); + if (port is not null) + { + return new ElkPoint + { + X = port.X + (port.Width / 2d), + Y = port.Y + (port.Height / 2d), + }; + } + } + + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var otherCenterX = otherNode.X + (otherNode.Width / 2d); + var otherCenterY = otherNode.Y + (otherNode.Height / 2d); + + if (Math.Abs(otherCenterX - nodeCenterX) < 0.001d + && Math.Abs(otherCenterY - nodeCenterY) < 0.001d) + { + return new ElkPoint + { + X = nodeCenterX, + Y = nodeCenterY, + }; + } + + return ResolvePreferredAnchorPoint(node, otherCenterX, otherCenterY, forcedSide, direction); + } + + private static (ElkPoint SourcePoint, ElkPoint TargetPoint) ResolveStraightChainAnchors( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint sourcePoint, + ElkPoint targetPoint, + string sourceSide, + string targetSide, + EdgeChannel channel, + ElkLayoutDirection direction) + { + if (direction == ElkLayoutDirection.LeftToRight) + { + if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.X < sourcePoint.X) + { + return (sourcePoint, targetPoint); + } + + var sharedY = sourceNode.Y + (sourceNode.Height / 2d); + return ( + ResolvePreferredAnchorPoint(sourceNode, targetNode.X, sharedY, sourceSide, direction), + ResolvePreferredAnchorPoint(targetNode, sourceNode.X + sourceNode.Width, sharedY, targetSide, direction)); + } + + if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.Y < sourcePoint.Y) + { + return (sourcePoint, targetPoint); + } + + var sharedX = sourceNode.X + (sourceNode.Width / 2d); + return ( + ResolvePreferredAnchorPoint(sourceNode, sharedX, targetNode.Y, sourceSide, direction), + ResolvePreferredAnchorPoint(targetNode, sharedX, sourceNode.Y + sourceNode.Height, targetSide, direction)); + } + + private static IReadOnlyCollection BuildHorizontalBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + if (channel.RouteMode == EdgeRouteMode.SinkOuter) + { + return BuildHorizontalSinkBendPoints( + sourceNode, + targetNode, + startPoint, + endPoint, + graphBounds, + channel, + layerBoundariesByNodeId); + } + + if (channel.RouteMode == EdgeRouteMode.SinkOuterTop) + { + return BuildHorizontalTopSinkBendPoints( + sourceNode, + targetNode, + startPoint, + endPoint, + graphBounds, + channel, + layerBoundariesByNodeId); + } + + if (Math.Abs(endPoint.Y - startPoint.Y) <= 6d && endPoint.X >= startPoint.X) + { + return []; + } + + if (channel.RouteMode == EdgeRouteMode.BackwardOuter || endPoint.X < startPoint.X) + { + return BuildHorizontalBackwardBendPoints( + sourceNode, + targetNode, + startPoint, + endPoint, + graphBounds, + channel, + layerBoundariesByNodeId); + } + + var baseChannelX = ResolveForwardChannelX(sourceNode, targetNode, startPoint, endPoint, channel); + + var channelX = Clamp(baseChannelX, startPoint.X + 12d, endPoint.X - 12d); + + return NormalizeBendPoints( + new ElkPoint { X = channelX, Y = startPoint.Y }, + new ElkPoint { X = channelX, Y = endPoint.Y }); + } + + private static double ResolveForwardChannelX( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + EdgeChannel channel) + { + if (!double.IsNaN(channel.PreferredDirectChannelX)) + { + return channel.PreferredDirectChannelX; + } + + if (ShouldPreferSourceLocalForwardDrop(sourceNode, targetNode, startPoint, endPoint, channel)) + { + var sourceLocalBase = Math.Max( + startPoint.X + 24d, + sourceNode.X + sourceNode.Width + 36d); + return sourceLocalBase + (channel.ForwardIndex * 36d); + } + + var baseChannelX = (sourceNode.X + sourceNode.Width + targetNode.X) / 2d; + if (channel.ForwardCount > 1) + { + var totalWidth = (channel.ForwardCount - 1) * 16d; + var offset = (channel.ForwardIndex * 16d) - (totalWidth / 2d); + baseChannelX += offset; + } + + return baseChannelX; + } + + private static double ResolveForwardSourceExitX( + ElkPositionedNode sourceNode, + ElkPoint startPoint, + EdgeChannel channel, + double baseOffset, + double spread) + { + var sourceLocalBase = Math.Max( + startPoint.X + 24d, + sourceNode.X + sourceNode.Width + baseOffset); + if (channel.ForwardCount <= 1) + { + return sourceLocalBase; + } + + return sourceLocalBase + (channel.ForwardIndex * spread); + } + + private static bool ShouldPreferSourceLocalForwardDrop( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + EdgeChannel channel) + { + if (channel.RouteMode != EdgeRouteMode.Direct || channel.ForwardCount <= 1) + { + return false; + } + + var verticalSpan = Math.Abs(endPoint.Y - startPoint.Y); + if (verticalSpan < 120d) + { + return false; + } + + var horizontalSpan = endPoint.X - startPoint.X; + if (horizontalSpan < 180d) + { + return false; + } + + return string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || verticalSpan > horizontalSpan * 0.45d; + } + + private static IReadOnlyCollection BuildVerticalBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + if (Math.Abs(endPoint.X - startPoint.X) <= 6d && endPoint.Y >= startPoint.Y) + { + return []; + } + + if (endPoint.Y < startPoint.Y) + { + var lane = Math.Max(0, channel.BackwardLane); + var outerX = graphBounds.MinX - 48d - (lane * 24d); + + if (channel.BackwardTargetCount > 1) + { + var spread = Math.Min(18d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); + var totalSpread = (channel.BackwardTargetCount - 1) * spread; + var adjustedEndX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); + adjustedEndX = Clamp(adjustedEndX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); + return NormalizeBendPoints( + new ElkPoint { X = outerX, Y = startPoint.Y }, + new ElkPoint { X = outerX, Y = endPoint.Y }, + new ElkPoint { X = adjustedEndX, Y = endPoint.Y }); + } + + return NormalizeBendPoints( + new ElkPoint { X = outerX, Y = startPoint.Y }, + new ElkPoint { X = outerX, Y = endPoint.Y }); + } + + var baseChannelY = (sourceNode.Y + sourceNode.Height + targetNode.Y) / 2d; + if (channel.ForwardCount > 1) + { + var totalHeight = (channel.ForwardCount - 1) * 16d; + var offset = (channel.ForwardIndex * 16d) - (totalHeight / 2d); + baseChannelY += offset; + } + + var channelY = Clamp(baseChannelY, startPoint.Y + 12d, endPoint.Y - 12d); + + return NormalizeBendPoints( + new ElkPoint { X = startPoint.X, Y = channelY }, + new ElkPoint { X = endPoint.X, Y = channelY }); + } + + private static IReadOnlyCollection BuildHorizontalBackwardBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var lane = Math.Max(0, channel.BackwardLane); + var isLowerCorridor = !double.IsNaN(channel.PreferredOuterY) + && channel.PreferredOuterY > Math.Max(startPoint.Y, endPoint.Y) + 4d; + var outerY = double.IsNaN(channel.PreferredOuterY) + ? graphBounds.MinY - 56d - (lane * 28d) + : isLowerCorridor + ? channel.PreferredOuterY + (lane * 24d) + : channel.PreferredOuterY - (lane * 24d); + var sourceBoundary = ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); + var sourceExitX = channel.UseSourceCollector + ? startPoint.X + : channel.SharedOuterX > 0d + ? Math.Max(startPoint.X + 18d, channel.SharedOuterX) + : Math.Max(startPoint.X + 18d, sourceBoundary.MaxX + 28d); + var approachX = endPoint.X; + + return NormalizeBendPoints( + new ElkPoint { X = startPoint.X, Y = outerY }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = approachX, Y = outerY }); + } + + private static IReadOnlyCollection BuildHorizontalSinkBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var targetBoundary = ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); + var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 36d, 36d); + var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); + var outerY = graphBounds.MaxY + 32d + ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex)); + + return NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = startPoint.Y }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + private static IReadOnlyCollection BuildHorizontalTopSinkBendPoints( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkPoint startPoint, + ElkPoint endPoint, + GraphBounds graphBounds, + EdgeChannel channel, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var sourceBoundary = ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); + var targetBoundary = ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); + var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 40d, 40d); + var sinkOffset = Math.Max(0, channel.SinkBandIndex) * 14d; + var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d - sinkOffset); + var outerY = double.IsNaN(channel.PreferredOuterY) + ? graphBounds.MinY - 56d - ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 36d, 28d) + : channel.PreferredOuterY + ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d); + + return NormalizeBendPoints( + new ElkPoint { X = sourceExitX, Y = startPoint.Y }, + new ElkPoint { X = sourceExitX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = outerY }, + new ElkPoint { X = targetApproachX, Y = endPoint.Y }); + } + + private static ElkPoint ResolvePreferredAnchorPoint( + ElkPositionedNode node, + double targetX, + double targetY, + string? forcedSide, + ElkLayoutDirection direction) + { + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var deltaX = targetX - nodeCenterX; + var deltaY = targetY - nodeCenterY; + var insetX = Math.Min(18d, node.Width / 4d); + var insetY = Math.Min(18d, node.Height / 4d); + + var preferredSide = forcedSide; + if (string.IsNullOrWhiteSpace(preferredSide)) + { + preferredSide = direction == ElkLayoutDirection.LeftToRight + ? (Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d + ? (deltaX >= 0d ? "EAST" : "WEST") + : (deltaY >= 0d ? "SOUTH" : "NORTH")) + : (Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d + ? (deltaY >= 0d ? "SOUTH" : "NORTH") + : (deltaX >= 0d ? "EAST" : "WEST")); + } + + var preferredTargetX = preferredSide switch + { + "EAST" => node.X + node.Width + 256d, + "WEST" => node.X - 256d, + _ => Clamp(targetX, node.X + insetX, node.X + node.Width - insetX), + }; + var preferredTargetY = preferredSide switch + { + "SOUTH" => node.Y + node.Height + 256d, + "NORTH" => node.Y - 256d, + _ => Clamp(targetY, node.Y + insetY, node.Y + node.Height - insetY), + }; + + var adjustedDeltaX = preferredTargetX - nodeCenterX; + var adjustedDeltaY = preferredTargetY - nodeCenterY; + + var candidate = new ElkPoint + { + X = preferredSide switch + { + "EAST" => node.X + node.Width, + "WEST" => node.X, + _ => Clamp(preferredTargetX, node.X + insetX, node.X + node.Width - insetX), + }, + Y = preferredSide switch + { + "SOUTH" => node.Y + node.Height, + "NORTH" => node.Y, + _ => Clamp(preferredTargetY, node.Y + insetY, node.Y + node.Height - insetY), + }, + }; + + return ResolveGatewayBoundaryPoint(node, candidate, adjustedDeltaX, adjustedDeltaY); + } + + private static Dictionary AssignLayersByInputOrder( + IReadOnlyCollection nodes, + IReadOnlyDictionary> outgoing, + IReadOnlyDictionary inputOrder, + IReadOnlySet backEdgeIds) + { + var layersByNodeId = nodes.ToDictionary(x => x.Id, _ => 0, StringComparer.Ordinal); + var orderedNodes = nodes + .OrderBy(node => inputOrder[node.Id], Comparer.Default) + .ToArray(); + + for (var iteration = 0; iteration < orderedNodes.Length; iteration++) + { + var changed = false; + foreach (var node in orderedNodes) + { + var sourceLayer = layersByNodeId[node.Id]; + foreach (var edge in outgoing[node.Id]) + { + if (backEdgeIds.Contains(edge.Id)) + { + continue; + } + + var candidateLayer = sourceLayer + 1; + if (candidateLayer <= layersByNodeId[edge.TargetNodeId]) + { + continue; + } + + layersByNodeId[edge.TargetNodeId] = candidateLayer; + changed = true; + } + } + + if (!changed) + { + break; + } + } + + for (var nodeIndex = orderedNodes.Length - 1; nodeIndex >= 0; nodeIndex--) + { + var node = orderedNodes[nodeIndex]; + var minSuccessorLayer = int.MaxValue; + foreach (var edge in outgoing[node.Id] + .Where(edge => !backEdgeIds.Contains(edge.Id))) + { + minSuccessorLayer = Math.Min(minSuccessorLayer, layersByNodeId[edge.TargetNodeId]); + } + + if (minSuccessorLayer != int.MaxValue) + { + var idealLayer = minSuccessorLayer - 1; + if (idealLayer > layersByNodeId[node.Id]) + { + layersByNodeId[node.Id] = idealLayer; + } + } + } + + return layersByNodeId; + } + + private static (string SourceSide, string TargetSide) ResolveRouteSides( + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + ElkLayoutDirection direction) + { + var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterX = targetNode.X + (targetNode.Width / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var deltaX = targetCenterX - sourceCenterX; + var deltaY = targetCenterY - sourceCenterY; + + if (direction == ElkLayoutDirection.LeftToRight) + { + if (Math.Abs(deltaX) >= 24d || Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d) + { + return deltaX >= 0d + ? ("EAST", "WEST") + : ("NORTH", "NORTH"); + } + + return deltaY >= 0d + ? ("SOUTH", "NORTH") + : ("NORTH", "SOUTH"); + } + + if (Math.Abs(deltaY) >= 24d || Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d) + { + return deltaY >= 0d + ? ("SOUTH", "NORTH") + : ("NORTH", "NORTH"); + } + + return deltaX >= 0d + ? ("EAST", "WEST") + : ("WEST", "EAST"); + } + + private static ElkNode[][] OptimizeLayerOrdering( + ElkNode[][] initialLayers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary inputOrder, + int iterationCount = 8) + { + if (initialLayers.Length <= 2) + { + return initialLayers; + } + + var layers = initialLayers + .Select(layer => layer.ToList()) + .ToArray(); + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++) + { + OrderLayer(layers, layerIndex, incomingNodeIds, inputOrder); + } + + for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--) + { + OrderLayer(layers, layerIndex, outgoingNodeIds, inputOrder); + } + } + + return layers + .Select(layer => layer.ToArray()) + .ToArray(); + } + + private static int ResolveOrderingIterationCount( + ElkLayoutOptions options, + int edgeCount, + int nodeCount) + { + if (options.OrderingIterations is int explicitIterations) + { + return Math.Max(2, explicitIterations); + } + + var baseline = Math.Max(6, Math.Max(edgeCount / 4, nodeCount / 3)); + return options.Effort switch + { + ElkLayoutEffort.Draft => Math.Min(8, baseline), + ElkLayoutEffort.Balanced => Math.Min(14, Math.Max(8, baseline)), + _ => Math.Min(24, Math.Max(12, baseline + 4)), + }; + } + + private static int ResolvePlacementIterationCount( + ElkLayoutOptions options, + int nodeCount, + int layerCount) + { + if (options.PlacementIterations is int explicitIterations) + { + return Math.Max(1, explicitIterations); + } + + var baseline = Math.Max(2, Math.Max(nodeCount / 8, layerCount / 2)); + return options.Effort switch + { + ElkLayoutEffort.Draft => Math.Min(3, baseline), + ElkLayoutEffort.Balanced => Math.Min(6, Math.Max(3, baseline)), + _ => Math.Min(10, Math.Max(5, baseline + 2)), + }; + } + + private static void RefineHorizontalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredY = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: true); + desiredY[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Height / 2d) + : positionedNodes[node.Id].Y; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + nodeSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + current.X, + desiredY[nodeIndex], + direction); + } + } + } + } + + private static void RefineVerticalPlacement( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + double nodeSpacing, + int iterationCount, + ElkLayoutDirection direction) + { + if (iterationCount <= 0) + { + return; + } + + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var layerIndices = iteration % 2 == 0 + ? Enumerable.Range(0, layers.Count) + : Enumerable.Range(0, layers.Count).Reverse(); + + foreach (var layerIndex in layerIndices) + { + var layer = layers[layerIndex]; + if (layer.Length == 0) + { + continue; + } + + var desiredX = new double[layer.Length]; + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: false); + desiredX[nodeIndex] = preferredCenter.HasValue + ? preferredCenter.Value - (node.Width / 2d) + : positionedNodes[node.Id].X; + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + nodeSpacing; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var current = positionedNodes[layer[nodeIndex].Id]; + positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( + nodesById[layer[nodeIndex].Id], + desiredX[nodeIndex], + current.Y, + direction); + } + } + } + } + + private static void SnapOriginalPrimaryAxes( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + var nodeDesiredPairs = new (ElkNode Node, double Desired)[actualNodes.Length]; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var positioned = positionedNodes[actualNodes[nodeIndex].Id]; + var preferredCenter = ResolveOriginalPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + nodeDesiredPairs[nodeIndex] = (actualNodes[nodeIndex], preferredCenter.HasValue + ? preferredCenter.Value - ((direction == ElkLayoutDirection.LeftToRight ? positioned.Height : positioned.Width) / 2d) + : (direction == ElkLayoutDirection.LeftToRight ? positioned.Y : positioned.X)); + } + + Array.Sort(nodeDesiredPairs, (a, b) => a.Desired.CompareTo(b.Desired)); + var sortedNodes = nodeDesiredPairs.Select(p => p.Node).ToArray(); + var desiredCoordinates = nodeDesiredPairs.Select(p => p.Desired).ToArray(); + + EnforceLinearSpacing( + sortedNodes, + desiredCoordinates, + nodeSpacing, + horizontal: direction == ElkLayoutDirection.LeftToRight); + + for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++) + { + var current = positionedNodes[sortedNodes[nodeIndex].Id]; + positionedNodes[sortedNodes[nodeIndex].Id] = direction == ElkLayoutDirection.LeftToRight + ? CreatePositionedNode(sortedNodes[nodeIndex], current.X, desiredCoordinates[nodeIndex], direction) + : CreatePositionedNode(sortedNodes[nodeIndex], desiredCoordinates[nodeIndex], current.Y, direction); + } + } + } + } + + private static void PropagateSuccessorPositionBackward( + Dictionary positionedNodes, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary originalNodesById, + ElkLayoutDirection direction) + { + var horizontal = direction == ElkLayoutDirection.LeftToRight; + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!originalNodesById.ContainsKey(nodeId)) + { + continue; + } + + var current = positionedNodes[nodeId]; + if (!string.Equals(current.Kind, "Fork", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!outgoingNodeIds.TryGetValue(nodeId, out var forkOutgoing) || forkOutgoing.Count < 2) + { + continue; + } + + var joinSuccessor = forkOutgoing + .Select(id => positionedNodes.GetValueOrDefault(id)) + .FirstOrDefault(n => n is not null + && string.Equals(n.Kind, "Join", StringComparison.OrdinalIgnoreCase)); + if (joinSuccessor is null) + { + continue; + } + + var joinCenter = horizontal + ? joinSuccessor.Y + (joinSuccessor.Height / 2d) + : joinSuccessor.X + (joinSuccessor.Width / 2d); + var forkCenter = horizontal + ? current.Y + (current.Height / 2d) + : current.X + (current.Width / 2d); + if (Math.Abs(joinCenter - forkCenter) < 20d) + { + continue; + } + + var chainNodeIds = new List { nodeId }; + var walkId = nodeId; + for (var step = 0; step < 20; step++) + { + var predecessor = positionedNodes.Keys + .Where(id => originalNodesById.ContainsKey(id) + && outgoingNodeIds.TryGetValue(id, out var outs) && outs.Count == 1 && outs[0] == walkId) + .FirstOrDefault(); + if (predecessor is null) + { + break; + } + + chainNodeIds.Add(predecessor); + walkId = predecessor; + } + + foreach (var chainId in chainNodeIds) + { + var pos = positionedNodes[chainId]; + var orig = originalNodesById[chainId]; + if (horizontal) + { + positionedNodes[chainId] = CreatePositionedNode( + orig, pos.X, joinCenter - (pos.Height / 2d), direction); + } + else + { + positionedNodes[chainId] = CreatePositionedNode( + orig, joinCenter - (pos.Width / 2d), pos.Y, direction); + } + } + } + } + + private static void CenterMultiIncomingNodes( + Dictionary positionedNodes, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary originalNodesById, + ElkLayoutDirection direction) + { + var horizontal = direction == ElkLayoutDirection.LeftToRight; + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!originalNodesById.TryGetValue(nodeId, out var originalNode)) + { + continue; + } + + var current = positionedNodes[nodeId]; + if (!string.Equals(current.Kind, "Join", StringComparison.OrdinalIgnoreCase) + && !string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count < 2) + { + continue; + } + + var centers = incomingIds + .Select(id => positionedNodes.GetValueOrDefault(id)) + .Where(n => n is not null) + .Select(n => horizontal ? n!.Y + (n.Height / 2d) : n!.X + (n.Width / 2d)) + .OrderBy(c => c) + .ToArray(); + + if (centers.Length < 2) + { + continue; + } + + double medianCenter; + if (string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + medianCenter = (centers[0] + centers[^1]) / 2d; + } + else + { + var mid = centers.Length / 2; + medianCenter = centers.Length % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + } + + if (horizontal) + { + var desiredY = medianCenter - (current.Height / 2d); + positionedNodes[nodeId] = CreatePositionedNode(originalNode, current.X, desiredY, direction); + } + else + { + var desiredX = medianCenter - (current.Width / 2d); + positionedNodes[nodeId] = CreatePositionedNode(originalNode, desiredX, current.Y, direction); + } + } + } + + private static void CompactTowardIncomingFlow( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary originalNodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 3; iteration++) + { + foreach (var layer in layers) + { + var actualNodes = layer + .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) + .Select(node => originalNodesById[node.Id]) + .ToArray(); + if (actualNodes.Length == 0) + { + continue; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + var previousBottom = double.NegativeInfinity; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var current = positionedNodes[actualNodes[nodeIndex].Id]; + var targetY = current.Y; + if (nodeIndex > 0) + { + targetY = Math.Max(targetY, previousBottom + nodeSpacing); + } + + if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) + { + var preferredCenter = ResolveIncomingPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + positionedNodes, + horizontal: true); + if (preferredCenter.HasValue) + { + targetY = Math.Max( + nodeIndex > 0 ? previousBottom + nodeSpacing : double.NegativeInfinity, + Math.Min(current.Y, preferredCenter.Value - (current.Height / 2d))); + } + } + + positionedNodes[actualNodes[nodeIndex].Id] = CreatePositionedNode( + actualNodes[nodeIndex], + current.X, + targetY, + direction); + previousBottom = targetY + current.Height; + } + + continue; + } + + var previousRight = double.NegativeInfinity; + for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) + { + var current = positionedNodes[actualNodes[nodeIndex].Id]; + var targetX = current.X; + if (nodeIndex > 0) + { + targetX = Math.Max(targetX, previousRight + nodeSpacing); + } + + if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) + { + var preferredCenter = ResolveIncomingPreferredCenter( + actualNodes[nodeIndex].Id, + incomingNodeIds, + positionedNodes, + horizontal: false); + if (preferredCenter.HasValue) + { + targetX = Math.Max( + nodeIndex > 0 ? previousRight + nodeSpacing : double.NegativeInfinity, + Math.Min(current.X, preferredCenter.Value - (current.Width / 2d))); + } + } + + positionedNodes[actualNodes[nodeIndex].Id] = CreatePositionedNode( + actualNodes[nodeIndex], + targetX, + current.Y, + direction); + previousRight = targetX + current.Width; + } + } + } + } + + private static bool ShouldCompactTowardIncoming( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary positionedNodes) + { + if (!positionedNodes.TryGetValue(nodeId, out var currentNode) + || string.Equals(currentNode.Kind, "Start", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) + { + return false; + } + + return incomingIds + .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) + .Where(incomingNode => incomingNode is not null) + .All(incomingNode => !string.Equals(incomingNode!.Kind, "Fork", StringComparison.OrdinalIgnoreCase)); + } + + private static double? ResolveIncomingPreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) + { + return null; + } + + var centers = incomingIds + .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) + .Where(incomingNode => incomingNode is not null) + .Select(incomingNode => horizontal + ? incomingNode!.Y + (incomingNode.Height / 2d) + : incomingNode!.X + (incomingNode.Width / 2d)) + .OrderBy(center => center) + .ToArray(); + if (centers.Length == 0) + { + return null; + } + + var mid = centers.Length / 2; + return centers.Length % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + } + + private static void AlignDummyNodesToFlow( + Dictionary positionedNodes, + IReadOnlyList layers, + IReadOnlySet dummyNodeIds, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary nodesById, + ElkLayoutDirection direction) + { + for (var iteration = 0; iteration < 2; iteration++) + { + foreach (var layer in layers) + { + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + if (!dummyNodeIds.Contains(node.Id)) + { + continue; + } + + var current = positionedNodes[node.Id]; + var preferredCenter = ResolvePreferredCenter( + node.Id, + incomingNodeIds, + outgoingNodeIds, + positionedNodes, + horizontal: direction == ElkLayoutDirection.LeftToRight); + if (!preferredCenter.HasValue) + { + continue; + } + + if (direction == ElkLayoutDirection.LeftToRight) + { + var minY = nodeIndex == 0 + ? double.NegativeInfinity + : positionedNodes[layer[nodeIndex - 1].Id].Y + positionedNodes[layer[nodeIndex - 1].Id].Height + 1d; + var maxY = nodeIndex == layer.Length - 1 + ? double.PositiveInfinity + : positionedNodes[layer[nodeIndex + 1].Id].Y - current.Height - 1d; + var desiredY = Clamp(preferredCenter.Value - (current.Height / 2d), minY, maxY); + positionedNodes[node.Id] = CreatePositionedNode(nodesById[node.Id], current.X, desiredY, direction); + continue; + } + + var minX = nodeIndex == 0 + ? double.NegativeInfinity + : positionedNodes[layer[nodeIndex - 1].Id].X + positionedNodes[layer[nodeIndex - 1].Id].Width + 1d; + var maxX = nodeIndex == layer.Length - 1 + ? double.PositiveInfinity + : positionedNodes[layer[nodeIndex + 1].Id].X - current.Width - 1d; + var desiredX = Clamp(preferredCenter.Value - (current.Width / 2d), minX, maxX); + positionedNodes[node.Id] = CreatePositionedNode(nodesById[node.Id], desiredX, current.Y, direction); + } + } + } + } + + private static double? ResolveOriginalPreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (!positionedNodes.TryGetValue(nodeId, out var currentNode)) + { + return null; + } + + incomingNodeIds.TryGetValue(nodeId, out var incomingIds); + if (incomingIds is not null + && string.Equals(currentNode.Kind, "Join", StringComparison.OrdinalIgnoreCase) + && incomingIds.Count > 0) + { + var branchCenters = incomingIds + .Select(adjacentNodeId => positionedNodes.GetValueOrDefault(adjacentNodeId)) + .Where(adjacentNode => adjacentNode is not null) + .Select(adjacentNode => horizontal + ? adjacentNode!.Y + (adjacentNode.Height / 2d) + : adjacentNode!.X + (adjacentNode.Width / 2d)) + .OrderBy(center => center) + .ToArray(); + if (branchCenters.Length > 0) + { + var mid = branchCenters.Length / 2; + return branchCenters.Length % 2 == 1 + ? branchCenters[mid] + : (branchCenters[mid - 1] + branchCenters[mid]) / 2d; + } + } + + if (incomingIds is not null + && incomingIds.Count == 1 + && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) + && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) + && predecessorOutgoing.Count == 1) + { + return horizontal + ? linearPredecessor.Y + (linearPredecessor.Height / 2d) + : linearPredecessor.X + (linearPredecessor.Width / 2d); + } + + if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) + && outgoingIds.Count == 1 + && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) + && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) + && successorIncoming.Count == 1) + { + return horizontal + ? linearSuccessor.Y + (linearSuccessor.Height / 2d) + : linearSuccessor.X + (linearSuccessor.Width / 2d); + } + + var allCenters = new List(); + if (incomingIds is not null) + { + foreach (var adjacentNodeId in incomingIds) + { + if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + allCenters.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + } + + if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) + { + foreach (var adjacentNodeId in outgoingIds) + { + if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + allCenters.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + } + + if (allCenters.Count > 0) + { + allCenters.Sort(); + var mid = allCenters.Count / 2; + return allCenters.Count % 2 == 1 + ? allCenters[mid] + : (allCenters[mid - 1] + allCenters[mid]) / 2d; + } + + return null; + } + + private static double? ResolvePreferredCenter( + string nodeId, + IReadOnlyDictionary> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary positionedNodes, + bool horizontal) + { + if (incomingNodeIds.TryGetValue(nodeId, out var incomingIds) + && incomingIds.Count == 1 + && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) + && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) + && predecessorOutgoing.Count == 1) + { + return horizontal + ? linearPredecessor.Y + (linearPredecessor.Height / 2d) + : linearPredecessor.X + (linearPredecessor.Width / 2d); + } + + if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) + && outgoingIds.Count == 1 + && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) + && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) + && successorIncoming.Count == 1) + { + return horizontal + ? linearSuccessor.Y + (linearSuccessor.Height / 2d) + : linearSuccessor.X + (linearSuccessor.Width / 2d); + } + + var coordinates = new List(); + if (incomingNodeIds.TryGetValue(nodeId, out incomingIds)) + { + foreach (var adjacentNodeId in incomingIds) + { + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + coordinates.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + + if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) + { + foreach (var adjacentNodeId in outgoingIds) + { + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + coordinates.Add(horizontal + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d)); + } + } + + if (coordinates.Count == 0) + { + return null; + } + + coordinates.Sort(); + var mid = coordinates.Count / 2; + return coordinates.Count % 2 == 1 + ? coordinates[mid] + : (coordinates[mid - 1] + coordinates[mid]) / 2d; + } + + private static void EnforceLinearSpacing( + IReadOnlyList layer, + double[] desiredCoordinates, + double spacing, + bool horizontal) + { + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + } + + for (var index = layer.Count - 2; index >= 0; index--) + { + var extent = horizontal ? layer[index].Height : layer[index].Width; + desiredCoordinates[index] = Math.Min( + desiredCoordinates[index], + desiredCoordinates[index + 1] - extent - spacing); + } + + for (var index = 1; index < layer.Count; index++) + { + var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; + desiredCoordinates[index] = Math.Max( + desiredCoordinates[index], + desiredCoordinates[index - 1] + extent + spacing); + } + } + + private static void OrderLayer( + IReadOnlyList> layers, + int layerIndex, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary inputOrder) + { + var positions = BuildNodeOrderPositions(layers); + var currentLayer = layers[layerIndex]; + currentLayer.Sort((left, right) => + { + var leftRank = ResolveOrderingRank(left.Id, adjacentNodeIds, positions); + var rightRank = ResolveOrderingRank(right.Id, adjacentNodeIds, positions); + var comparison = leftRank.CompareTo(rightRank); + if (comparison != 0) + { + return comparison; + } + + comparison = positions[left.Id].CompareTo(positions[right.Id]); + if (comparison != 0) + { + return comparison; + } + + return inputOrder[left.Id].CompareTo(inputOrder[right.Id]); + }); + } + + private static Dictionary BuildNodeOrderPositions(IReadOnlyList> layers) + { + var positions = new Dictionary(StringComparer.Ordinal); + foreach (var layer in layers) + { + for (var index = 0; index < layer.Count; index++) + { + positions[layer[index].Id] = index; + } + } + + return positions; + } + + private static double ResolveOrderingRank( + string nodeId, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary positions) + { + if (!adjacentNodeIds.TryGetValue(nodeId, out var neighbors) || neighbors.Count == 0) + { + return double.PositiveInfinity; + } + + var ordered = neighbors + .Where(positions.ContainsKey) + .Select(neighborId => (double)positions[neighborId]) + .OrderBy(value => value) + .ToArray(); + + if (ordered.Length == 0) + { + return double.PositiveInfinity; + } + + var middle = ordered.Length / 2; + return ordered.Length % 2 == 1 + ? ordered[middle] + : (ordered[middle - 1] + ordered[middle]) / 2d; + } + + private static IReadOnlyCollection NormalizeBendPoints(params ElkPoint[] points) + { + if (points.Length == 0) + { + return []; + } + + var normalized = new List(points.Length); + foreach (var point in points) + { + if (normalized.Count > 0 + && Math.Abs(normalized[^1].X - point.X) <= 0.01d + && Math.Abs(normalized[^1].Y - point.Y) <= 0.01d) + { + continue; + } + + normalized.Add(point); + } + + return normalized; + } + + private static (Dictionary InputOrder, HashSet BackEdgeIds) BuildTraversalInputOrder( + IReadOnlyCollection nodes, + IReadOnlyCollection edges, + IReadOnlyDictionary nodesById) + { + var originalOrder = nodes + .Select((node, index) => new KeyValuePair(node.Id, index)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + var outgoing = nodes.ToDictionary(node => node.Id, _ => new List(), StringComparer.Ordinal); + var incomingCount = nodes.ToDictionary(node => node.Id, _ => 0, StringComparer.Ordinal); + + foreach (var edge in edges) + { + outgoing[edge.SourceNodeId].Add(edge); + incomingCount[edge.TargetNodeId] = incomingCount[edge.TargetNodeId] + 1; + } + + var orderedNodeIds = new List(nodes.Count); + var visited = new HashSet(StringComparer.Ordinal); + var onStack = new HashSet(StringComparer.Ordinal); + var backEdgeIds = new HashSet(StringComparer.Ordinal); + var preferredRoots = nodes + .Where(node => string.Equals(node.Kind, "Start", StringComparison.Ordinal)) + .Concat(nodes.Where(node => !string.Equals(node.Kind, "Start", StringComparison.Ordinal) && incomingCount[node.Id] == 0)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default) + .ToArray(); + + foreach (var root in preferredRoots) + { + Visit(root.Id); + } + + foreach (var node in nodes + .Where(node => !string.Equals(node.Kind, "End", StringComparison.Ordinal)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default)) + { + Visit(node.Id); + } + + foreach (var endNode in nodes + .Where(node => string.Equals(node.Kind, "End", StringComparison.Ordinal)) + .OrderBy(node => originalOrder[node.Id], Comparer.Default)) + { + Visit(endNode.Id); + } + + var inputOrder = orderedNodeIds + .Select((nodeId, index) => new KeyValuePair(nodeId, index)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + + return (inputOrder, backEdgeIds); + + void Visit(string nodeId) + { + if (!visited.Add(nodeId)) + { + return; + } + + onStack.Add(nodeId); + orderedNodeIds.Add(nodeId); + foreach (var edge in outgoing[nodeId] + .OrderBy(edge => string.Equals(nodesById[edge.TargetNodeId].Kind, "End", StringComparison.Ordinal) ? 1 : 0) + .ThenBy(edge => originalOrder[edge.TargetNodeId], Comparer.Default)) + { + if (onStack.Contains(edge.TargetNodeId)) + { + backEdgeIds.Add(edge.Id); + } + + Visit(edge.TargetNodeId); + } + + onStack.Remove(nodeId); + } + } + + private static ElkPoint IntersectDiamondBoundary( + double centerX, + double centerY, + double halfWidth, + double halfHeight, + double deltaX, + double deltaY) + { + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + return new ElkPoint + { + X = centerX, + Y = centerY, + }; + } + + var scale = 1d / ((Math.Abs(deltaX) / Math.Max(halfWidth, 0.001d)) + (Math.Abs(deltaY) / Math.Max(halfHeight, 0.001d))); + return new ElkPoint + { + X = centerX + (deltaX * scale), + Y = centerY + (deltaY * scale), + }; + } + + private static ElkPoint ResolveGatewayBoundaryPoint( + ElkPositionedNode node, + ElkPoint candidate, + double deltaX, + double deltaY) + { + if (node.Kind is not ("Decision" or "Fork" or "Join")) + { + return candidate; + } + + var centerX = node.X + (node.Width / 2d); + var centerY = node.Y + (node.Height / 2d); + if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) + { + deltaX = candidate.X - centerX; + deltaY = candidate.Y - centerY; + } + + if (node.Kind == "Decision") + { + return IntersectDiamondBoundary(centerX, centerY, node.Width / 2d, node.Height / 2d, deltaX, deltaY); + } + + return IntersectPolygonBoundary( + centerX, + centerY, + deltaX, + deltaY, + BuildForkBoundaryPoints(node)); + } + + private static IReadOnlyList BuildForkBoundaryPoints(ElkPositionedNode node) + { + var cornerInset = Math.Min(22d, Math.Max(6d, node.Width * 0.125d)); + var verticalInset = Math.Min(8d, Math.Max(4d, node.Height * 0.065d)); + return + [ + new ElkPoint { X = node.X + cornerInset, Y = node.Y + verticalInset }, + new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + verticalInset }, + new ElkPoint { X = node.X + node.Width, Y = node.Y + (node.Height / 2d) }, + new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + node.Height - verticalInset }, + new ElkPoint { X = node.X + cornerInset, Y = node.Y + node.Height - verticalInset }, + new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) }, + ]; + } + + private static ElkPoint IntersectPolygonBoundary( + double originX, + double originY, + double deltaX, + double deltaY, + IReadOnlyList polygon) + { + var bestScale = double.PositiveInfinity; + ElkPoint? bestPoint = null; + for (var index = 0; index < polygon.Count; index++) + { + var start = polygon[index]; + var end = polygon[(index + 1) % polygon.Count]; + if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) + { + continue; + } + + if (scale < bestScale) + { + bestScale = scale; + bestPoint = point; + } + } + + return bestPoint ?? new ElkPoint + { + X = originX + deltaX, + Y = originY + deltaY, + }; + } + + private static bool TryIntersectRayWithSegment( + double originX, + double originY, + double deltaX, + double deltaY, + ElkPoint segmentStart, + ElkPoint segmentEnd, + out double scale, + out ElkPoint point) + { + scale = double.PositiveInfinity; + point = default!; + + var segmentDeltaX = segmentEnd.X - segmentStart.X; + var segmentDeltaY = segmentEnd.Y - segmentStart.Y; + var denominator = Cross(deltaX, deltaY, segmentDeltaX, segmentDeltaY); + if (Math.Abs(denominator) <= 0.001d) + { + return false; + } + + var relativeX = segmentStart.X - originX; + var relativeY = segmentStart.Y - originY; + var rayScale = Cross(relativeX, relativeY, segmentDeltaX, segmentDeltaY) / denominator; + var segmentScale = Cross(relativeX, relativeY, deltaX, deltaY) / denominator; + if (rayScale < 0d || segmentScale < 0d || segmentScale > 1d) + { + return false; + } + + scale = rayScale; + point = new ElkPoint + { + X = originX + (deltaX * rayScale), + Y = originY + (deltaY * rayScale), + }; + return true; + } + + private static double Cross(double ax, double ay, double bx, double by) + { + return (ax * by) - (ay * bx); + } + + private static DummyNodeResult InsertDummyNodes( + IReadOnlyCollection originalNodes, + IReadOnlyCollection originalEdges, + Dictionary layersByNodeId, + IReadOnlyDictionary inputOrder, + IReadOnlySet backEdgeIds) + { + var allNodes = new List(originalNodes); + var allEdges = new List(); + var augmentedLayers = new Dictionary(layersByNodeId, StringComparer.Ordinal); + var augmentedInputOrder = new Dictionary(inputOrder, StringComparer.Ordinal); + var dummyNodeIds = new HashSet(StringComparer.Ordinal); + var edgeDummyChains = new Dictionary>(StringComparer.Ordinal); + var nextInputOrder = inputOrder.Values.Max() + 1; + + foreach (var edge in originalEdges) + { + if (backEdgeIds.Contains(edge.Id)) + { + allEdges.Add(edge); + continue; + } + + var sourceLayer = layersByNodeId.GetValueOrDefault(edge.SourceNodeId, 0); + var targetLayer = layersByNodeId.GetValueOrDefault(edge.TargetNodeId, 0); + var span = targetLayer - sourceLayer; + + if (span <= 1) + { + allEdges.Add(edge); + continue; + } + + var chain = new List(); + var previousNodeId = edge.SourceNodeId; + + for (var layer = sourceLayer + 1; layer < targetLayer; layer++) + { + var dummyId = $"__dummy_{edge.Id}_{layer}"; + var dummyNode = new ElkNode + { + Id = dummyId, + Label = string.Empty, + Kind = "Dummy", + Width = 1, + Height = 1, + }; + + allNodes.Add(dummyNode); + augmentedLayers[dummyId] = layer; + augmentedInputOrder[dummyId] = inputOrder.GetValueOrDefault(edge.SourceNodeId, nextInputOrder); + dummyNodeIds.Add(dummyId); + chain.Add(dummyId); + + allEdges.Add(new ElkEdge + { + Id = $"{edge.Id}__seg_{layer}", + SourceNodeId = previousNodeId, + TargetNodeId = dummyId, + }); + + previousNodeId = dummyId; + } + + allEdges.Add(new ElkEdge + { + Id = $"{edge.Id}__seg_{targetLayer}", + SourceNodeId = previousNodeId, + TargetNodeId = edge.TargetNodeId, + }); + + edgeDummyChains[edge.Id] = chain; + } + + return new DummyNodeResult(allNodes, allEdges, augmentedLayers, augmentedInputOrder, dummyNodeIds, edgeDummyChains); + } + + private static Dictionary ReconstructDummyEdges( + IReadOnlyCollection originalEdges, + DummyNodeResult dummyResult, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary augmentedNodesById, + ElkLayoutDirection direction, + GraphBounds graphBounds, + IReadOnlyDictionary edgeChannels, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var edgesWithChains = originalEdges + .Where(e => dummyResult.EdgeDummyChains.ContainsKey(e.Id)) + .ToArray(); + + var incomingByTarget = edgesWithChains + .GroupBy(e => e.TargetNodeId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(e => + { + var s = positionedNodes[e.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? s.Y + (s.Height / 2d) + : s.X + (s.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var outgoingBySource = edgesWithChains + .GroupBy(e => e.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.OrderBy(e => + { + var t = positionedNodes[e.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? t.Y + (t.Height / 2d) + : t.X + (t.Width / 2d); + }).ToArray(), StringComparer.Ordinal); + + var reconstructed = new Dictionary(StringComparer.Ordinal); + + foreach (var edge in edgesWithChains) + { + if (!dummyResult.EdgeDummyChains.TryGetValue(edge.Id, out var chain) || chain.Count == 0) + { + continue; + } + + var channel = edgeChannels.GetValueOrDefault(edge.Id); + if (channel.RouteMode != EdgeRouteMode.Direct + || !string.IsNullOrWhiteSpace(edge.SourcePortId) + || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var sourceNode = positionedNodes[edge.SourceNodeId]; + var targetNode = positionedNodes[edge.TargetNodeId]; + var sourceGroup = outgoingBySource.GetValueOrDefault(edge.SourceNodeId); + var targetGroup = incomingByTarget.GetValueOrDefault(edge.TargetNodeId); + + if (ShouldRouteLongEdgeViaDirectRouter(edge, sourceNode, targetNode, sourceGroup, targetGroup, positionedNodes, direction)) + { + reconstructed[edge.Id] = RouteEdge( + edge, + augmentedNodesById, + positionedNodes, + direction, + graphBounds, + channel, + layerBoundariesByNodeId); + continue; + } + + var bendPoints = new List(); + foreach (var dummyId in chain) + { + if (positionedNodes.TryGetValue(dummyId, out var dummyPos)) + { + bendPoints.Add(new ElkPoint + { + X = dummyPos.X + (dummyPos.Width / 2d), + Y = dummyPos.Y + (dummyPos.Height / 2d), + }); + } + } + + var sourceExitY = ResolveGroupedAnchorCoordinate(sourceNode, edge, sourceGroup, positionedNodes, isSource: true, direction); + var targetEntryY = ResolveGroupedAnchorCoordinate(targetNode, edge, targetGroup, positionedNodes, isSource: false, direction); + + var targetCenter = new ElkPoint + { + X = targetNode.X + (targetNode.Width / 2d), + Y = targetNode.Y + (targetNode.Height / 2d), + }; + var sourceAnchor = ComputeSmartAnchor(sourceNode, targetCenter, + true, sourceExitY, sourceGroup?.Length ?? 1, direction); + var targetAnchor = ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null, + false, targetEntryY, targetGroup?.Length ?? 1, direction); + + reconstructed[edge.Id] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + SourcePortId = edge.SourcePortId, + TargetPortId = edge.TargetPortId, + Kind = edge.Kind, + Label = edge.Label, + Sections = + [ + new ElkEdgeSection + { + StartPoint = sourceAnchor, + EndPoint = targetAnchor, + BendPoints = bendPoints, + }, + ], + }; + } + + return reconstructed; + } + + private static bool ShouldRouteLongEdgeViaDirectRouter( + ElkEdge edge, + ElkPositionedNode sourceNode, + ElkPositionedNode targetNode, + IReadOnlyList? sourceGroup, + IReadOnlyList? targetGroup, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var rowTolerance = Math.Max(28d, Math.Min(sourceNode.Height, targetNode.Height) * 0.4d); + if (Math.Abs(sourceCenterY - targetCenterY) <= rowTolerance) + { + return true; + } + + var sourcePrimaryIndex = ResolvePrimaryAxisGroupIndex(sourceNode, sourceGroup, positionedNodes, isSource: true, direction); + var targetPrimaryIndex = ResolvePrimaryAxisGroupIndex(targetNode, targetGroup, positionedNodes, isSource: false, direction); + if (sourcePrimaryIndex < 0 && targetPrimaryIndex < 0) + { + return false; + } + + var sourceIndex = sourceGroup is null + ? -1 + : Array.FindIndex(sourceGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + var targetIndex = targetGroup is null + ? -1 + : Array.FindIndex(targetGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + + return sourceIndex >= 0 && sourceIndex == sourcePrimaryIndex + || targetIndex >= 0 && targetIndex == targetPrimaryIndex; + } + + private static double ResolveGroupedAnchorCoordinate( + ElkPositionedNode node, + ElkEdge edge, + IReadOnlyList? group, + IReadOnlyDictionary positionedNodes, + bool isSource, + ElkLayoutDirection direction) + { + var center = direction == ElkLayoutDirection.LeftToRight + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + if (group is null || group.Count <= 1) + { + return center; + } + + var index = Array.FindIndex(group.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); + if (index < 0) + { + return center; + } + + var primaryIndex = ResolvePrimaryAxisGroupIndex(node, group, positionedNodes, isSource, direction); + if (primaryIndex >= 0) + { + var spread = direction == ElkLayoutDirection.LeftToRight + ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) + : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); + var coordinate = center + ((index - primaryIndex) * spread); + return direction == ElkLayoutDirection.LeftToRight + ? Clamp(coordinate, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(coordinate, node.X + 6d, node.X + node.Width - 6d); + } + + var fallbackSpread = direction == ElkLayoutDirection.LeftToRight + ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) + : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); + var total = (group.Count - 1) * fallbackSpread; + var fallback = center - (total / 2d) + (index * fallbackSpread); + return direction == ElkLayoutDirection.LeftToRight + ? Clamp(fallback, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(fallback, node.X + 6d, node.X + node.Width - 6d); + } + + private static int ResolvePrimaryAxisGroupIndex( + ElkPositionedNode node, + IReadOnlyList? group, + IReadOnlyDictionary positionedNodes, + bool isSource, + ElkLayoutDirection direction) + { + if (group is null || group.Count == 0) + { + return -1; + } + + var reserveAxis = isSource + ? string.Equals(node.Kind, "Fork", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + : string.Equals(node.Kind, "Join", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase); + if (!reserveAxis) + { + return -1; + } + + var center = direction == ElkLayoutDirection.LeftToRight + ? node.Y + (node.Height / 2d) + : node.X + (node.Width / 2d); + var bestIndex = 0; + var bestDistance = double.PositiveInfinity; + for (var index = 0; index < group.Count; index++) + { + var adjacentNodeId = isSource ? group[index].TargetNodeId : group[index].SourceNodeId; + if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) + { + continue; + } + + var adjacentCenter = direction == ElkLayoutDirection.LeftToRight + ? adjacent.Y + (adjacent.Height / 2d) + : adjacent.X + (adjacent.Width / 2d); + var distance = Math.Abs(adjacentCenter - center); + if (distance < bestDistance) + { + bestDistance = distance; + bestIndex = index; + } + } + + return bestIndex; + } + + private static ElkPoint ComputeSmartAnchor( + ElkPositionedNode node, + ElkPoint? approachPoint, + bool isSource, + double spreadY, + int groupSize, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight || approachPoint is null) + { + var fallback = isSource + ? new ElkPoint { X = node.X + node.Width, Y = Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) } + : new ElkPoint { X = node.X, Y = Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) }; + return ResolveGatewayBoundaryPoint( + node, + fallback, + fallback.X - (node.X + (node.Width / 2d)), + fallback.Y - (node.Y + (node.Height / 2d))); + } + + var nodeCenterX = node.X + (node.Width / 2d); + var nodeCenterY = node.Y + (node.Height / 2d); + var deltaX = approachPoint.X - nodeCenterX; + var deltaY = approachPoint.Y - nodeCenterY; + + if (isSource) + { + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY < 0d) + { + var topCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y, + }; + return ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY > 0d) + { + var bottomCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y + node.Height, + }; + return ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); + } + + var eastCandidate = new ElkPoint + { + X = node.X + node.Width, + Y = groupSize > 1 ? Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), + }; + return ResolveGatewayBoundaryPoint(node, eastCandidate, eastCandidate.X - nodeCenterX, eastCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY < 0d) + { + var topCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y, + }; + return ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); + } + + if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY > 0d) + { + var bottomCandidate = new ElkPoint + { + X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), + Y = node.Y + node.Height, + }; + return ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); + } + + var westCandidate = new ElkPoint + { + X = node.X, + Y = groupSize > 1 ? Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) + : Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), + }; + return ResolveGatewayBoundaryPoint(node, westCandidate, westCandidate.X - nodeCenterX, westCandidate.Y - nodeCenterY); + } + + private sealed record DummyNodeResult( + List AllNodes, + List AllEdges, + Dictionary AugmentedLayers, + Dictionary AugmentedInputOrder, + HashSet DummyNodeIds, + Dictionary> EdgeDummyChains); + + private static GraphBounds ComputeGraphBounds(ICollection nodes) + { + return new GraphBounds( + nodes.Min(n => n.X), + nodes.Min(n => n.Y), + nodes.Max(n => n.X + n.Width), + nodes.Max(n => n.Y + n.Height)); + } + + private static bool ExpandVerticalCorridorGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double baseLayerSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .OrderBy(group => group.Key) + .Select(group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new + { + Layer = group.Key, + Boundary = new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)), + }; + }) + .ToArray(); + if (boundariesByLayer.Length < 2) + { + return false; + } + + var requiredBoundaryDeltas = new Dictionary(); + for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) + { + var current = boundariesByLayer[boundaryIndex]; + var next = boundariesByLayer[boundaryIndex + 1]; + var gap = next.Boundary.MinX - current.Boundary.MaxX; + if (gap <= 0d) + { + continue; + } + + var verticalSegments = routedEdges + .SelectMany(edge => edge.Sections.SelectMany(section => + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points.Zip(points.Skip(1), (start, end) => new + { + Edge = edge, + Start = start, + End = end, + }); + })) + .Where(segment => + Math.Abs(segment.Start.X - segment.End.X) <= 0.01d + && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d + && segment.Start.X > current.Boundary.MaxX + 8d + && segment.Start.X < next.Boundary.MinX - 8d) + .ToArray(); + var laneCount = verticalSegments + .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) + .Distinct() + .Count(); + if (laneCount == 0) + { + continue; + } + + var familyCount = verticalSegments + .Select(segment => ResolveLaneFamilyKey(segment.Edge.Label)) + .Distinct(StringComparer.Ordinal) + .Count(); + var desiredGap = Math.Max( + baseLayerSpacing + 88d, + 136d + (laneCount * 28d) + (Math.Max(0, familyCount - 1) * 24d)); + if (gap >= desiredGap) + { + continue; + } + + requiredBoundaryDeltas[current.Layer] = desiredGap - gap; + } + + if (requiredBoundaryDeltas.Count == 0) + { + return false; + } + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) + { + continue; + } + + var shiftX = requiredBoundaryDeltas + .Where(entry => nodeLayer > entry.Key) + .Sum(entry => entry.Value); + if (shiftX <= 0.01d) + { + continue; + } + + var current = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); + } + + return true; + } + + private static bool CompactSparseVerticalCorridorGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double baseLayerSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .OrderBy(group => group.Key) + .Select(group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new + { + Layer = group.Key, + Boundary = new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)), + }; + }) + .ToArray(); + if (boundariesByLayer.Length < 2) + { + return false; + } + + var boundaryShifts = new Dictionary(); + for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) + { + var current = boundariesByLayer[boundaryIndex]; + var next = boundariesByLayer[boundaryIndex + 1]; + var gap = next.Boundary.MinX - current.Boundary.MaxX; + if (gap <= 0d) + { + continue; + } + + var verticalSegments = routedEdges + .SelectMany(edge => edge.Sections.SelectMany(section => + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + return points.Zip(points.Skip(1), (start, end) => new + { + Edge = edge, + Start = start, + End = end, + }); + })) + .Where(segment => + Math.Abs(segment.Start.X - segment.End.X) <= 0.01d + && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d + && segment.Start.X > current.Boundary.MaxX + 8d + && segment.Start.X < next.Boundary.MinX - 8d) + .ToArray(); + var laneCount = verticalSegments + .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) + .Distinct() + .Count(); + var familyCount = verticalSegments + .Select(segment => ResolveLaneFamilyKey(segment.Edge.Label)) + .Distinct(StringComparer.Ordinal) + .Count(); + + var desiredGap = Math.Max( + baseLayerSpacing * 0.72d, + 120d + (laneCount * 20d) + (Math.Max(0, familyCount - 1) * 16d)); + var maxGap = desiredGap + 28d; + if (gap <= maxGap) + { + continue; + } + + boundaryShifts[current.Layer] = desiredGap - gap; + } + + if (boundaryShifts.Count == 0) + { + return false; + } + + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) + { + continue; + } + + var shiftX = boundaryShifts + .Where(entry => nodeLayer > entry.Key) + .Sum(entry => entry.Value); + if (Math.Abs(shiftX) <= 0.01d) + { + continue; + } + + var current = positionedNodes[nodeId]; + positionedNodes[nodeId] = CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); + } + + return true; + } + + private static double ResolveBackwardCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var endpointTop = double.PositiveInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + endpointTop = Math.Min(endpointTop, Math.Min(source.Y, target.Y)); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + var maxAllowed = endpointTop - 24d; + if (maxAllowed <= merged[0].Top) + { + return double.NaN; + } + + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = merged[index].Bottom; + var freeBottom = Math.Min(maxAllowed, merged[index + 1].Top); + var gapHeight = freeBottom - freeTop; + if (gapHeight < 56d) + { + continue; + } + + var candidateMin = freeTop + 18d; + var candidateMax = freeBottom - 12d; + if (candidateMax <= candidateMin) + { + continue; + } + + var desiredY = Math.Min(maxAllowed - 8d, freeTop + (gapHeight * 0.72d)); + foreach (var fraction in s_corridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + [], + false, + freeTop); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + private static double ResolveBackwardLowerCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var minAllowed = double.NegativeInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + minAllowed = Math.Max(minAllowed, Math.Max(source.Y + source.Height, target.Y + target.Height) + 14d); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = Math.Max(minAllowed, merged[index].Bottom); + var freeBottom = merged[index + 1].Top; + var gapHeight = freeBottom - freeTop; + if (gapHeight < 44d) + { + continue; + } + + var candidateMin = freeTop + 12d; + var candidateMax = freeBottom - 12d; + if (candidateMax <= candidateMin) + { + continue; + } + + var desiredY = freeTop + Math.Min(40d, gapHeight * 0.32d); + foreach (var fraction in s_corridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + [], + true, + minAllowed) + + 28d; + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + private static double ResolveSinkCorridorY( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + IReadOnlyCollection reservedBands) + { + if (edges.Count == 0) + { + return double.NaN; + } + + var spanMinX = double.PositiveInfinity; + var spanMaxX = double.NegativeInfinity; + var minAllowed = double.NegativeInfinity; + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); + spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); + minAllowed = Math.Max(minAllowed, source.Y + source.Height + 18d); + } + + var spanNodes = positionedNodes.Values + .Where(node => + !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) + && (node.X + node.Width) >= spanMinX - 1d + && node.X <= spanMaxX + 1d) + .ToArray(); + var occupiedIntervals = spanNodes + .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) + .OrderBy(interval => interval.Top) + .ToArray(); + if (occupiedIntervals.Length == 0) + { + return double.NaN; + } + + var merged = new List<(double Top, double Bottom)>(); + foreach (var interval in occupiedIntervals) + { + if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) + { + merged.Add(interval); + continue; + } + + merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); + } + + var occupiedBottom = merged[^1].Bottom; + var desiredY = minAllowed + Math.Min(280d, Math.Max(0d, occupiedBottom - minAllowed) * 0.35d); + double bestScore = double.NegativeInfinity; + double bestCandidate = double.NaN; + + for (var index = 0; index < merged.Count - 1; index++) + { + var freeTop = Math.Max(minAllowed, merged[index].Bottom); + var freeBottom = merged[index + 1].Top; + var gapHeight = freeBottom - freeTop; + if (gapHeight < 56d) + { + continue; + } + + var candidateMin = freeTop + 20d; + var candidateMax = freeBottom - 20d; + if (candidateMax <= candidateMin) + { + continue; + } + + foreach (var fraction in s_corridorSampleFractions) + { + var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); + var score = ScoreHorizontalCorridorCandidate( + spanNodes, + freeTop, + freeBottom, + candidate, + desiredY, + reservedBands, + true, + minAllowed); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestCandidate = candidate; + } + } + + return bestCandidate; + } + + private static double ResolveSinkBandOffset(int bandIndex, double firstSpacing = 36d, double subsequentSpacing = 28d) + { + if (bandIndex <= 0) + { + return 0d; + } + + return firstSpacing + ((bandIndex - 1) * subsequentSpacing); + } + + private static readonly double[] s_corridorSampleFractions = [0.2d, 0.35d, 0.5d, 0.65d, 0.8d]; + + private static double ScoreHorizontalCorridorCandidate( + IReadOnlyCollection spanNodes, + double freeTop, + double freeBottom, + double candidate, + double desiredY, + IReadOnlyCollection reservedBands, + bool rewardInterior, + double minAllowed) + { + var gapHeight = freeBottom - freeTop; + var clearance = Math.Min(candidate - freeTop, freeBottom - candidate); + var bandSeparation = reservedBands.Count == 0 + ? 144d + : reservedBands.Min(band => Math.Abs(candidate - band)); + var rowPenalty = spanNodes.Sum(node => + { + var nodeTop = node.Y - 6d; + var nodeBottom = node.Y + node.Height + 6d; + if (candidate >= nodeTop && candidate <= nodeBottom) + { + return 100000d; + } + + var centerY = node.Y + (node.Height / 2d); + var distance = Math.Abs(centerY - candidate); + return distance >= 96d ? 0d : (96d - distance) * 0.32d; + }); + + return gapHeight + + (clearance * 1.8d) + + (Math.Min(120d, bandSeparation) * 0.7d) + - (Math.Abs(candidate - desiredY) * 0.35d) + - rowPenalty + + (rewardInterior && freeTop >= minAllowed + 64d ? 22d : 0d); + } + + private static bool CanUseLocalSinkDrop( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var sourceExitX = source.X + source.Width + 28d; + var sourceBottom = source.Y + source.Height; + var targetMidY = target.Y + (target.Height / 2d); + + return positionedNodes.Values + .Where(node => + !string.Equals(node.Id, source.Id, StringComparison.Ordinal) + && !string.Equals(node.Id, target.Id, StringComparison.Ordinal) + && !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase)) + .All(node => + sourceExitX < node.X - 12d + || sourceExitX > node.X + node.Width + 12d + || node.Y >= targetMidY + || node.Y + node.Height <= sourceBottom + 8d); + } + + private static bool ShouldPreferBottomSinkGutter( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + if (!string.Equals(target.Kind, "End", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var horizontalSpan = target.X - (source.X + source.Width); + var verticalGap = target.Y - (source.Y + source.Height); + return horizontalSpan >= 960d && verticalGap >= 160d; + } + + private static Dictionary ComputeEdgeChannels( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + ElkLayoutDirection direction, + IReadOnlyDictionary layerBoundariesByNodeId) + { + var channels = new Dictionary(edges.Count, StringComparer.Ordinal); + var backwardEdges = new List(); + var forwardEdgesBySource = new Dictionary>(StringComparer.Ordinal); + var outgoingCounts = edges + .GroupBy(edge => edge.SourceNodeId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + + foreach (var edge in edges) + { + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var isBackward = direction == ElkLayoutDirection.LeftToRight + ? (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d)) + : (target.Y + (target.Height / 2d)) < (source.Y + (source.Height / 2d)); + + if (isBackward) + { + backwardEdges.Add(edge); + } + else + { + if (!forwardEdgesBySource.TryGetValue(edge.SourceNodeId, out var list)) + { + list = []; + forwardEdgesBySource[edge.SourceNodeId] = list; + } + + list.Add(edge); + } + } + + var backwardGroups = backwardEdges + .GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal) + .SelectMany(targetGroup => + { + var families = targetGroup + .GroupBy(edge => ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) + .OrderBy(group => ResolveLaneFamilyPriority(group.First().Label)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + return families.Select((familyGroup, familyIndex) => new + { + TargetNodeId = targetGroup.Key, + FamilyKey = familyGroup.Key, + FamilyIndex = familyIndex, + FamilyCount = families.Length, + Edges = familyGroup.ToArray(), + SharedOuterX = familyGroup.Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + return s.X + s.Width; + }) + 56d, + Priority = ResolveLaneFamilyPriority(familyGroup.First().Label), + Span = familyGroup.Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + var t = positionedNodes[edge.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? Math.Abs((s.X + (s.Width / 2d)) - (t.X + (t.Width / 2d))) + : Math.Abs((s.Y + (s.Height / 2d)) - (t.Y + (t.Height / 2d))); + }), + }); + }) + .OrderByDescending(group => group.Span) + .ThenBy(group => group.Priority) + .ThenBy(group => group.TargetNodeId, StringComparer.Ordinal) + .ToArray(); + + var reservedHorizontalBands = new List(); + for (var laneIndex = 0; laneIndex < backwardGroups.Length; laneIndex++) + { + var group = backwardGroups[laneIndex]; + var useSourceCollector = string.Equals(group.FamilyKey, "repeat", StringComparison.Ordinal); + var preferredOuterY = double.NaN; + if (direction == ElkLayoutDirection.LeftToRight && useSourceCollector) + { + var lowerCorridorY = ResolveBackwardLowerCorridorY(group.Edges, positionedNodes); + preferredOuterY = !double.IsNaN(lowerCorridorY) + ? lowerCorridorY + : ResolveBackwardCorridorY(group.Edges, positionedNodes); + } + + if (!double.IsNaN(preferredOuterY)) + { + reservedHorizontalBands.Add(preferredOuterY); + } + + foreach (var edge in group.Edges) + { + channels[edge.Id] = new EdgeChannel( + EdgeRouteMode.BackwardOuter, + laneIndex, + group.FamilyIndex, + group.FamilyCount, + 0, + 1, + 0, + 1, + -1, + 0, + group.SharedOuterX, + preferredOuterY, + useSourceCollector, + double.NaN); + } + } + + var forwardEdgesByTarget = new Dictionary>(StringComparer.Ordinal); + foreach (var sourceEdges in forwardEdgesBySource.Values) + { + foreach (var edge in sourceEdges) + { + if (!forwardEdgesByTarget.TryGetValue(edge.TargetNodeId, out var list)) + { + list = []; + forwardEdgesByTarget[edge.TargetNodeId] = list; + } + + list.Add(edge); + } + } + + var sinkBandsByEdgeId = new Dictionary(StringComparer.Ordinal); + if (direction == ElkLayoutDirection.LeftToRight) + { + var reservedSinkBands = new List(reservedHorizontalBands); + foreach (var targetEdges in forwardEdgesByTarget) + { + var targetNode = positionedNodes[targetEdges.Key]; + var isSinkTarget = string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + || !outgoingCounts.ContainsKey(targetEdges.Key); + if (!isSinkTarget || targetEdges.Value.Count < 2) + { + continue; + } + + var sinkBands = targetEdges.Value + .GroupBy(edge => ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) + .OrderBy(group => ResolveSinkLanePriority(group.First().Label)) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToArray(); + for (var bandIndex = 0; bandIndex < sinkBands.Length; bandIndex++) + { + var sinkBandEdges = sinkBands[bandIndex].ToArray(); + var sharedOuterX = sinkBands[bandIndex].Max(edge => + { + var s = positionedNodes[edge.SourceNodeId]; + return s.X + s.Width; + }) + 56d; + var familyKey = ResolveLaneFamilyKey(sinkBands[bandIndex].First().Label); + var preferredOuterY = familyKey is "failure" or "timeout" + ? ResolveSinkCorridorY(sinkBandEdges, positionedNodes, reservedSinkBands) + : double.NaN; + if (!double.IsNaN(preferredOuterY)) + { + reservedSinkBands.Add(preferredOuterY + (bandIndex * 24d)); + } + + foreach (var edge in sinkBandEdges) + { + sinkBandsByEdgeId[edge.Id] = (bandIndex, sinkBands.Length, sharedOuterX, preferredOuterY); + } + } + } + } + + foreach (var sourceEdges in forwardEdgesBySource.Values) + { + var sorted = sourceEdges + .OrderBy(e => + { + var t = positionedNodes[e.TargetNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? t.Y + (t.Height / 2d) + : t.X + (t.Width / 2d); + }) + .ToArray(); + + for (var index = 0; index < sorted.Length; index++) + { + var targetEdges = forwardEdgesByTarget.GetValueOrDefault(sorted[index].TargetNodeId); + var targetIncomingIndex = 0; + var targetIncomingCount = 1; + if (targetEdges is not null && targetEdges.Count > 1) + { + var sortedBySourceY = targetEdges + .OrderBy(e => + { + var s = positionedNodes[e.SourceNodeId]; + return direction == ElkLayoutDirection.LeftToRight + ? s.Y + (s.Height / 2d) + : s.X + (s.Width / 2d); + }) + .ToList(); + targetIncomingIndex = sortedBySourceY.FindIndex(e => string.Equals(e.Id, sorted[index].Id, StringComparison.Ordinal)); + targetIncomingCount = sortedBySourceY.Count; + } + + var sinkBand = sinkBandsByEdgeId.GetValueOrDefault(sorted[index].Id, (-1, 0, 0d, double.NaN)); + var routeMode = EdgeRouteMode.Direct; + if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) + { + var familyKey = ResolveLaneFamilyKey(sorted[index].Label); + if (familyKey is "failure" or "timeout") + { + routeMode = EdgeRouteMode.SinkOuterTop; + } + else + { + routeMode = EdgeRouteMode.SinkOuter; + } + } + + channels[sorted[index].Id] = new EdgeChannel( + routeMode, + -1, + 0, + 1, + index, + sorted.Length, + targetIncomingIndex, + targetIncomingCount, + sinkBand.Item1, + sinkBand.Item2, + sinkBand.Item3, + sinkBand.Item4, + false, + double.NaN); + } + } + + AllocateDirectForwardChannelBands(edges, positionedNodes, layerBoundariesByNodeId, channels, direction); + + return channels; + } + + private static void AllocateDirectForwardChannelBands( + IReadOnlyCollection edges, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layerBoundariesByNodeId, + Dictionary channels, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return; + } + + var candidates = edges + .Select(edge => TryCreateDirectChannelCandidate(edge, positionedNodes, layerBoundariesByNodeId, channels)) + .Where(candidate => candidate is not null) + .Select(candidate => candidate!.Value) + .GroupBy(candidate => candidate.GapKey, StringComparer.Ordinal); + + foreach (var gapGroup in candidates) + { + var ordered = gapGroup + .OrderBy(candidate => candidate.TargetCenterY) + .ThenBy(candidate => candidate.TargetX) + .ThenBy(candidate => candidate.SourceCenterY) + .ThenBy(candidate => candidate.FamilyPriority) + .ThenBy(candidate => candidate.EdgeId, StringComparer.Ordinal) + .ToArray(); + if (ordered.Length == 0) + { + continue; + } + + var gapMinX = ordered.Max(candidate => candidate.GapMinX); + var gapMaxX = ordered.Min(candidate => candidate.GapMaxX); + if (gapMaxX - gapMinX < 24d) + { + continue; + } + + var edgePadding = Math.Min(28d, Math.Max(16d, (gapMaxX - gapMinX) * 0.12d)); + var usableMinX = gapMinX + edgePadding; + var usableMaxX = gapMaxX - edgePadding; + if (usableMaxX <= usableMinX) + { + usableMinX = gapMinX + 12d; + usableMaxX = gapMaxX - 12d; + } + + for (var index = 0; index < ordered.Length; index++) + { + var preferredX = ordered.Length == 1 + ? (usableMinX + usableMaxX) / 2d + : usableMinX + ((usableMaxX - usableMinX) * (index / (double)(ordered.Length - 1))); + preferredX = Clamp(preferredX, ordered[index].GapMinX + 8d, ordered[index].GapMaxX - 8d); + + if (!channels.TryGetValue(ordered[index].EdgeId, out var channel)) + { + continue; + } + + channels[ordered[index].EdgeId] = channel with + { + PreferredDirectChannelX = preferredX, + }; + } + } + } + + private static DirectChannelCandidate? TryCreateDirectChannelCandidate( + ElkEdge edge, + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layerBoundariesByNodeId, + IReadOnlyDictionary channels) + { + if (!channels.TryGetValue(edge.Id, out var channel) || channel.RouteMode != EdgeRouteMode.Direct) + { + return null; + } + + var source = positionedNodes[edge.SourceNodeId]; + var target = positionedNodes[edge.TargetNodeId]; + var sourceCenterX = source.X + (source.Width / 2d); + var targetCenterX = target.X + (target.Width / 2d); + if (targetCenterX <= sourceCenterX + 1d) + { + return null; + } + + var sourceCenterY = source.Y + (source.Height / 2d); + var targetCenterY = target.Y + (target.Height / 2d); + if (Math.Abs(targetCenterY - sourceCenterY) < 56d) + { + return null; + } + + var sourceBoundary = ResolveLayerBoundary(edge.SourceNodeId, layerBoundariesByNodeId, source); + var targetBoundary = ResolveLayerBoundary(edge.TargetNodeId, layerBoundariesByNodeId, target); + var gapMinX = sourceBoundary.MaxX + 12d; + var gapMaxX = targetBoundary.MinX - 12d; + if (gapMaxX - gapMinX < 48d) + { + return null; + } + + var gapKey = $"{Math.Round(gapMinX, 2):0.##}|{Math.Round(gapMaxX, 2):0.##}"; + return new DirectChannelCandidate( + edge.Id, + gapKey, + gapMinX, + gapMaxX, + ResolveLaneFamilyPriority(edge.Label), + sourceCenterY, + targetCenterY, + target.X); + } + + private static string ResolveLaneFamilyKey(string? label) + { + if (string.IsNullOrWhiteSpace(label)) + { + return "default"; + } + + var normalized = label.Trim().ToLowerInvariant(); + if (normalized.Contains("failure", StringComparison.Ordinal)) + { + return "failure"; + } + + if (normalized.Contains("timeout", StringComparison.Ordinal)) + { + return "timeout"; + } + + if (normalized.StartsWith("repeat ", StringComparison.Ordinal) + || normalized.Equals("body", StringComparison.Ordinal)) + { + return "repeat"; + } + + if (normalized.StartsWith("when ", StringComparison.Ordinal)) + { + return "success"; + } + + if (normalized.Contains("otherwise", StringComparison.Ordinal) + || normalized.Contains("default", StringComparison.Ordinal)) + { + return "default"; + } + + if (normalized.Contains("missing condition", StringComparison.Ordinal)) + { + return "missing-condition"; + } + + return "default"; + } + + private static int ResolveLaneFamilyPriority(string? label) + { + return ResolveLaneFamilyKey(label) switch + { + "failure" => 0, + "timeout" => 1, + "repeat" => 2, + "default" => 3, + "success" => 4, + "missing-condition" => 5, + _ => 6, + }; + } + + private static int ResolveSinkLanePriority(string? label) + { + return ResolveLaneFamilyKey(label) switch + { + "default" => 0, + "success" => 1, + "repeat" => 2, + "timeout" => 3, + "failure" => 4, + "missing-condition" => 5, + _ => 6, + }; + } + + private static Dictionary BuildLayerBoundariesByNodeId( + IReadOnlyDictionary positionedNodes, + IReadOnlyDictionary layersByNodeId) + { + var boundariesByLayer = layersByNodeId + .Where(entry => positionedNodes.ContainsKey(entry.Key)) + .GroupBy(entry => entry.Value) + .ToDictionary( + group => group.Key, + group => + { + var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); + return new LayerBoundary( + nodes.Min(node => node.X), + nodes.Max(node => node.X + node.Width), + nodes.Min(node => node.Y), + nodes.Max(node => node.Y + node.Height)); + }); + + return layersByNodeId + .Where(entry => boundariesByLayer.ContainsKey(entry.Value)) + .ToDictionary(entry => entry.Key, entry => boundariesByLayer[entry.Value], StringComparer.Ordinal); + } + + private static LayerBoundary ResolveLayerBoundary( + string nodeId, + IReadOnlyDictionary layerBoundariesByNodeId, + ElkPositionedNode fallbackNode) + { + return layerBoundariesByNodeId.TryGetValue(nodeId, out var boundary) + ? boundary + : new LayerBoundary( + fallbackNode.X, + fallbackNode.X + fallbackNode.Width, + fallbackNode.Y, + fallbackNode.Y + fallbackNode.Height); + } + + private static ElkRoutedEdge[] AvoidNodeCrossings( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0) + { + return edges; + } + + const double margin = 18d; + var obstacles = nodes.Select(n => ( + Left: n.X - margin, Top: n.Y - margin, + Right: n.X + n.Width + margin, Bottom: n.Y + n.Height + margin, + Id: n.Id + )).ToArray(); + + var result = new ElkRoutedEdge[edges.Length]; + for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) + { + var edge = edges[edgeIndex]; + var sourceId = edge.SourceNodeId ?? ""; + var targetId = edge.TargetNodeId ?? ""; + + var hasCrossing = false; + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + for (var i = 0; i < pts.Count - 1 && !hasCrossing; i++) + { + hasCrossing = SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId); + } + } + + if (!hasCrossing) + { + result[edgeIndex] = edge; + continue; + } + + var newSections = new List(edge.Sections.Count); + foreach (var section in edge.Sections) + { + var rerouted = RerouteWithGridAStar( + section.StartPoint, section.EndPoint, + obstacles, sourceId, targetId, margin); + + if (rerouted is not null && rerouted.Count >= 2) + { + newSections.Add(new ElkEdgeSection + { + StartPoint = rerouted[0], + EndPoint = rerouted[^1], + BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), + }); + } + else + { + newSections.Add(section); + } + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + private static bool SegmentCrossesObstacle( + ElkPoint p1, ElkPoint p2, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId) + { + var segLen = Math.Sqrt(Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2)); + var isH = Math.Abs(p1.Y - p2.Y) < 2d; + var isV = Math.Abs(p1.X - p2.X) < 2d; + if (!isH && !isV) return segLen > 15d; + + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + if (isH && p1.Y > ob.Top && p1.Y < ob.Bottom) + { + var minX = Math.Min(p1.X, p2.X); + var maxX = Math.Max(p1.X, p2.X); + if (maxX > ob.Left && minX < ob.Right) return true; + } + else if (isV && p1.X > ob.Left && p1.X < ob.Right) + { + var minY = Math.Min(p1.Y, p2.Y); + var maxY = Math.Max(p1.Y, p2.Y); + if (maxY > ob.Top && minY < ob.Bottom) return true; + } + } + + return false; + } + + private static List? RerouteWithGridAStar( + ElkPoint start, ElkPoint end, + (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, + string sourceId, string targetId, + double margin) + { + var xs = new SortedSet { start.X, end.X }; + var ys = new SortedSet { start.Y, end.Y }; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + xs.Add(ob.Left - margin); + xs.Add(ob.Right + margin); + ys.Add(ob.Top - margin); + ys.Add(ob.Bottom + margin); + } + + var xArr = xs.ToArray(); + var yArr = ys.ToArray(); + var xCount = xArr.Length; + var yCount = yArr.Length; + if (xCount < 2 || yCount < 2) return null; + + var startIx = Array.BinarySearch(xArr, start.X); + var startIy = Array.BinarySearch(yArr, start.Y); + var endIx = Array.BinarySearch(xArr, end.X); + var endIy = Array.BinarySearch(yArr, end.Y); + if (startIx < 0 || startIy < 0 || endIx < 0 || endIy < 0) return null; + + bool IsBlocked(int ix1, int iy1, int ix2, int iy2) + { + var x1 = xArr[ix1]; var y1 = yArr[iy1]; + var x2 = xArr[ix2]; var y2 = yArr[iy2]; + foreach (var ob in obstacles) + { + if (ob.Id == sourceId || ob.Id == targetId) continue; + if (ix1 == ix2) + { + var segX = x1; + if (segX > ob.Left && segX < ob.Right) + { + var minY = Math.Min(y1, y2); + var maxY = Math.Max(y1, y2); + if (maxY > ob.Top && minY < ob.Bottom) return true; + } + } + else if (iy1 == iy2) + { + var segY = y1; + if (segY > ob.Top && segY < ob.Bottom) + { + var minX = Math.Min(x1, x2); + var maxX = Math.Max(x1, x2); + if (maxX > ob.Left && minX < ob.Right) return true; + } + } + } + + return false; + } + + // A* with (ix, iy, direction) state; direction: 0=none, 1=horizontal, 2=vertical + const double bendPenalty = 200d; + var stateCount = xCount * yCount * 3; + var gScore = new double[stateCount]; + Array.Fill(gScore, double.MaxValue); + var cameFrom = new int[stateCount]; + Array.Fill(cameFrom, -1); + + int StateId(int ix, int iy, int dir) => (ix * yCount + iy) * 3 + dir; + double Heuristic(int ix, int iy) => + Math.Abs(xArr[ix] - xArr[endIx]) + Math.Abs(yArr[iy] - yArr[endIy]); + + var startState = StateId(startIx, startIy, 0); + gScore[startState] = 0d; + var openSet = new PriorityQueue(); + openSet.Enqueue(startState, Heuristic(startIx, startIy)); + + var dx = new[] { 1, -1, 0, 0 }; + var dy = new[] { 0, 0, 1, -1 }; + var dirs = new[] { 1, 1, 2, 2 }; + + var maxIterations = xCount * yCount * 6; + var iterations = 0; + var closed = new HashSet(); + while (openSet.Count > 0 && iterations++ < maxIterations) + { + var current = openSet.Dequeue(); + + if (!closed.Add(current)) + { + continue; + } + + var curDir = current % 3; + var curIy = (current / 3) % yCount; + var curIx = (current / 3) / yCount; + + if (curIx == endIx && curIy == endIy) + { + var path = new List(); + var state = current; + while (state >= 0) + { + var sIy = (state / 3) % yCount; + var sIx = (state / 3) / yCount; + path.Add(new ElkPoint { X = xArr[sIx], Y = yArr[sIy] }); + state = cameFrom[state]; + } + + path.Reverse(); + var simplified = new List { path[0] }; + for (var i = 1; i < path.Count - 1; i++) + { + var prev = simplified[^1]; + var next = path[i + 1]; + if (Math.Abs(prev.X - path[i].X) > 0.5d || Math.Abs(path[i].X - next.X) > 0.5d) + { + if (Math.Abs(prev.Y - path[i].Y) > 0.5d || Math.Abs(path[i].Y - next.Y) > 0.5d) + { + simplified.Add(path[i]); + } + } + } + + simplified.Add(path[^1]); + return simplified; + } + + for (var d = 0; d < 4; d++) + { + var nx = curIx + dx[d]; + var ny = curIy + dy[d]; + if (nx < 0 || nx >= xCount || ny < 0 || ny >= yCount) continue; + if (IsBlocked(curIx, curIy, nx, ny)) continue; + + var newDir = dirs[d]; + var bend = (curDir != 0 && curDir != newDir) ? bendPenalty : 0d; + var dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]); + var tentativeG = gScore[current] + dist + bend; + var neighborState = StateId(nx, ny, newDir); + + if (tentativeG < gScore[neighborState]) + { + gScore[neighborState] = tentativeG; + cameFrom[neighborState] = current; + var f = tentativeG + Heuristic(nx, ny); + openSet.Enqueue(neighborState, f); + } + } + } + + return null; + } + + private static ElkRoutedEdge[] DistributeOverlappingPorts( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return edges; + } + + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var result = edges.ToArray(); + + var incomingByTarget = edges + .Select((e, i) => (Edge: e, Index: i)) + .Where(x => x.Edge.TargetNodeId is not null) + .GroupBy(x => x.Edge.TargetNodeId!, StringComparer.Ordinal) + .Where(g => g.Count() >= 2 && nodesById.ContainsKey(g.Key)); + + foreach (var group in incomingByTarget) + { + var targetNode = nodesById[group.Key]; + var edgesWithIndices = group.ToArray(); + + var sideGroups = edgesWithIndices + .GroupBy(x => + { + var sec = x.Edge.Sections.Last(); + var ep = sec.EndPoint; + var allPoints = new List { sec.StartPoint }; + allPoints.AddRange(sec.BendPoints); + allPoints.Add(ep); + + var nodeCenterY = targetNode.Y + targetNode.Height / 2d; + var minPathY = allPoints.Min(p => p.Y); + var maxPathY = allPoints.Max(p => p.Y); + + if (minPathY < targetNode.Y - 20d) + { + return "NORTH"; + } + + if (maxPathY > targetNode.Y + targetNode.Height + 20d) + { + return "SOUTH"; + } + + if (Math.Abs(ep.X - targetNode.X) < 2d) return "WEST"; + if (Math.Abs(ep.X - (targetNode.X + targetNode.Width)) < 2d) return "EAST"; + if (ep.X < targetNode.X + targetNode.Width / 2d) return "WEST"; + return "EAST"; + }); + + foreach (var sideGroup in sideGroups) + { + var items = sideGroup.OrderBy(x => + { + var sec = x.Edge.Sections.Last(); + var bps = sec.BendPoints; + var approachPoint = bps.Count >= 2 + ? bps.ElementAt(bps.Count - 2) + : bps.Count == 1 ? bps.First() : sec.StartPoint; + return sideGroup.Key is "WEST" or "EAST" ? approachPoint.Y : approachPoint.X; + }).ToArray(); + + if (items.Length == 0) + { + continue; + } + + var needsSideReassignment = items.Length == 1 + && sideGroup.Key is "NORTH" or "SOUTH"; + var hasOverlap = needsSideReassignment; + for (var i = 1; i < items.Length; i++) + { + var prevEp = items[i - 1].Edge.Sections.Last().EndPoint; + var currEp = items[i].Edge.Sections.Last().EndPoint; + var prevApproach = items[i - 1].Edge.Sections.Last().BendPoints.Count > 0 + ? items[i - 1].Edge.Sections.Last().BendPoints.Last() : items[i - 1].Edge.Sections.Last().StartPoint; + var currApproach = items[i].Edge.Sections.Last().BendPoints.Count > 0 + ? items[i].Edge.Sections.Last().BendPoints.Last() : items[i].Edge.Sections.Last().StartPoint; + var isEndNode = string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase); + if ((Math.Abs(prevEp.X - currEp.X) < 4d && Math.Abs(prevEp.Y - currEp.Y) < 4d) + || (sideGroup.Key is "WEST" or "EAST" && Math.Abs(prevApproach.Y - currApproach.Y) > 40d) + || (isEndNode && items.Length >= 3)) + { + hasOverlap = true; + break; + } + } + + if (!hasOverlap) + { + continue; + } + + var isVerticalSide = sideGroup.Key is "WEST" or "EAST"; + var sideExtent = isVerticalSide ? targetNode.Height : targetNode.Width; + var spacing = Math.Min(20d, (sideExtent - 12d) / Math.Max(1, items.Length)); + var totalSpread = (items.Length - 1) * spacing; + var startOffset = (sideExtent / 2d) - (totalSpread / 2d); + + for (var i = 0; i < items.Length; i++) + { + var edge = items[i].Edge; + var edgeIdx = items[i].Index; + var lastSection = edge.Sections.Last(); + var oldEnd = lastSection.EndPoint; + var offset = startOffset + (i * spacing); + + ElkPoint newEnd; + if (isVerticalSide) + { + var sideX = sideGroup.Key == "WEST" ? targetNode.X : targetNode.X + targetNode.Width; + newEnd = new ElkPoint { X = sideX, Y = targetNode.Y + offset }; + } + else + { + var sideY = sideGroup.Key == "NORTH" ? targetNode.Y : targetNode.Y + targetNode.Height; + newEnd = new ElkPoint { X = targetNode.X + offset, Y = sideY }; + } + + newEnd = new ElkPoint + { + X = Clamp(newEnd.X, targetNode.X, targetNode.X + targetNode.Width), + Y = Clamp(newEnd.Y, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d), + }; + + // No node-specific logic — SnapAnchorsToNodeBoundary (later pass) + // will project all endpoints onto actual shape boundaries generically. + + var newBendPoints = lastSection.BendPoints.ToList(); + if (newBendPoints.Count > 0) + { + if (isVerticalSide) + { + newBendPoints[^1] = new ElkPoint { X = newBendPoints[^1].X, Y = newEnd.Y }; + } + else + { + newBendPoints[^1] = new ElkPoint { X = newEnd.X, Y = newBendPoints[^1].Y }; + } + } + + var newSections = edge.Sections.ToList(); + newSections[^1] = new ElkEdgeSection + { + StartPoint = lastSection.StartPoint, + EndPoint = newEnd, + BendPoints = newBendPoints.ToArray(), + }; + + result[edgeIdx] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + } + } + + return result; + } + + private static ElkRoutedEdge[] ReassignEdgesToCorrectSide( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var result = edges.ToArray(); + + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + if (edge.TargetNodeId is null || !nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)) + { + continue; + } + + var section = edge.Sections.FirstOrDefault(); + if (section is null) + { + continue; + } + + var allPoints = new List { section.StartPoint }; + allPoints.AddRange(section.BendPoints); + allPoints.Add(section.EndPoint); + + var minPathY = allPoints.Min(p => p.Y); + var maxPathY = allPoints.Max(p => p.Y); + var ep = section.EndPoint; + + string? newSide = null; + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + if (minPathY < graphMinY - 10d && Math.Abs(ep.X - targetNode.X) < 3d) + { + newSide = "NORTH"; + } + else if (maxPathY > graphMaxY + 10d && Math.Abs(ep.X - targetNode.X) < 3d) + { + newSide = "SOUTH"; + } + + if (newSide is null) + { + continue; + } + + var newEndY = newSide == "NORTH" ? targetNode.Y : targetNode.Y + targetNode.Height; + var newEndX = targetNode.X + targetNode.Width / 2d; + var newEnd = new ElkPoint { X = newEndX, Y = newEndY }; + + var newBendPoints = section.BendPoints.ToList(); + if (newBendPoints.Count > 0) + { + newBendPoints[^1] = new ElkPoint { X = newEndX, Y = newBendPoints[^1].Y }; + } + + var originalNode = nodesById.GetValueOrDefault(edge.TargetNodeId); + result[i] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = [new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = newEnd, + BendPoints = newBendPoints.ToArray(), + }], + }; + } + + return result; + } + + private static ElkRoutedEdge[] SimplifyEdgePaths( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray(); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var excludeIds = new HashSet(StringComparer.Ordinal) { edge.SourceNodeId ?? "", edge.TargetNodeId ?? "" }; + var anyChanged = false; + var newSections = new List(edge.Sections.Count); + + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + // Pass 1: Remove collinear points + var cleaned = new List { pts[0] }; + for (var j = 1; j < pts.Count - 1; j++) + { + var prev = cleaned[^1]; + var curr = pts[j]; + var next = pts[j + 1]; + var sameX = Math.Abs(prev.X - curr.X) < 1d && Math.Abs(curr.X - next.X) < 1d; + var sameY = Math.Abs(prev.Y - curr.Y) < 1d && Math.Abs(curr.Y - next.Y) < 1d; + if (!sameX && !sameY) + { + cleaned.Add(curr); + } + } + cleaned.Add(pts[^1]); + + // Pass 2: Try L-shape shortcuts for each triple + var changed = true; + var simplifyPass = 0; + while (changed && simplifyPass++ < 20) + { + changed = false; + for (var j = 0; j + 2 < cleaned.Count; j++) + { + var a = cleaned[j]; + var c = cleaned[j + 2]; + var corner1 = new ElkPoint { X = a.X, Y = c.Y }; + var corner2 = new ElkPoint { X = c.X, Y = a.Y }; + + foreach (var corner in new[] { corner1, corner2 }) + { + if (SegmentClearsObstacles(a, corner, obstacles, excludeIds) + && SegmentClearsObstacles(corner, c, obstacles, excludeIds)) + { + cleaned[j + 1] = corner; + changed = true; + anyChanged = true; + break; + } + } + } + } + + // Remove trailing duplicates (bend point == endpoint) + while (cleaned.Count > 2 + && Math.Abs(cleaned[^1].X - cleaned[^2].X) < 1d + && Math.Abs(cleaned[^1].Y - cleaned[^2].Y) < 1d) + { + cleaned.RemoveAt(cleaned.Count - 2); + } + + // Remove leading duplicates (start point == first bend) + while (cleaned.Count > 2 + && Math.Abs(cleaned[0].X - cleaned[1].X) < 1d + && Math.Abs(cleaned[0].Y - cleaned[1].Y) < 1d) + { + cleaned.RemoveAt(1); + } + + newSections.Add(new ElkEdgeSection + { + StartPoint = cleaned[0], + EndPoint = cleaned[^1], + BendPoints = cleaned.Skip(1).Take(cleaned.Count - 2).ToArray(), + }); + } + + result[i] = anyChanged + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + private static bool SegmentClearsObstacles( + ElkPoint p1, ElkPoint p2, + (double L, double T, double R, double B, string Id)[] obstacles, + HashSet excludeIds) + { + var isH = Math.Abs(p1.Y - p2.Y) < 1d; + var isV = Math.Abs(p1.X - p2.X) < 1d; + if (!isH && !isV) return true; + + foreach (var ob in obstacles) + { + if (excludeIds.Contains(ob.Id)) continue; + if (isH && p1.Y > ob.T && p1.Y < ob.B) + { + if (Math.Max(p1.X, p2.X) > ob.L && Math.Min(p1.X, p2.X) < ob.R) return false; + } + else if (isV && p1.X > ob.L && p1.X < ob.R) + { + if (Math.Max(p1.Y, p2.Y) > ob.T && Math.Min(p1.Y, p2.Y) < ob.B) return false; + } + } + return true; + } + + private static ElkRoutedEdge[] SnapAnchorsToNodeBoundary( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var anyChanged = false; + var newSections = edge.Sections.ToList(); + + for (var s = 0; s < newSections.Count; s++) + { + var section = newSections[s]; + var startFixed = false; + var endFixed = false; + var newStart = section.StartPoint; + var newEnd = section.EndPoint; + + if (edge.SourceNodeId is not null && nodesById.TryGetValue(edge.SourceNodeId, out var srcNode) && s == 0) + { + if (newStart.X > srcNode.X + 1d && newStart.X < srcNode.X + srcNode.Width - 1d + && newStart.Y > srcNode.Y + 1d && newStart.Y < srcNode.Y + srcNode.Height - 1d) + { + var target = section.BendPoints.Count > 0 ? section.BendPoints.First() : section.EndPoint; + newStart = ProjectOntoShapeBoundary(srcNode, target); + startFixed = true; + } + } + + if (edge.TargetNodeId is not null && nodesById.TryGetValue(edge.TargetNodeId, out var tgtNode) && s == newSections.Count - 1) + { + var source = section.BendPoints.Count > 0 ? section.BendPoints.Last() : section.StartPoint; + var projected = ProjectOntoShapeBoundary(tgtNode, source); + if (Math.Abs(projected.X - newEnd.X) > 3d || Math.Abs(projected.Y - newEnd.Y) > 3d) + { + newEnd = projected; + endFixed = true; + } + } + + if (startFixed || endFixed) + { + anyChanged = true; + newSections[s] = new ElkEdgeSection + { + StartPoint = newStart, + EndPoint = newEnd, + BendPoints = section.BendPoints, + }; + } + } + + result[i] = anyChanged + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + private static ElkPoint ProjectOntoShapeBoundary(ElkPositionedNode node, ElkPoint toward) + { + if (node.Kind is "Decision" or "Fork" or "Join") + { + var cx = node.X + node.Width / 2d; + var cy = node.Y + node.Height / 2d; + var dx = toward.X - cx; + var dy = toward.Y - cy; + return ResolveGatewayBoundaryPoint(node, toward, dx, dy); + } + + return ProjectOntoRectBoundary(node, toward); + } + + private static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward) + { + var cx = node.X + node.Width / 2d; + var cy = node.Y + node.Height / 2d; + var hw = node.Width / 2d; + var hh = node.Height / 2d; + var dx = toward.X - cx; + var dy = toward.Y - cy; + + if (Math.Abs(dx) < 0.1d && Math.Abs(dy) < 0.1d) + { + return new ElkPoint { X = cx + hw, Y = cy }; + } + + var tMin = double.MaxValue; + if (dx > 0.1d) { var t = hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } + if (dx < -0.1d) { var t = -hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } + if (dy > 0.1d) { var t = hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } + if (dy < -0.1d) { var t = -hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } + + return tMin < double.MaxValue + ? new ElkPoint { X = cx + dx * tMin, Y = cy + dy * tMin } + : new ElkPoint { X = cx + hw, Y = cy }; + } + + private static ElkRoutedEdge[] EliminateDiagonalSegments(ElkRoutedEdge[] edges) + { + var result = new ElkRoutedEdge[edges.Length]; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var anyFixed = false; + var newSections = new List(); + + foreach (var section in edge.Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + var fixedPts = new List { pts[0] }; + for (var j = 1; j < pts.Count; j++) + { + var prev = fixedPts[^1]; + var curr = pts[j]; + var dx = Math.Abs(curr.X - prev.X); + var dy = Math.Abs(curr.Y - prev.Y); + if (dx > 3d && dy > 3d) + { + fixedPts.Add(new ElkPoint { X = prev.X, Y = curr.Y }); + anyFixed = true; + } + fixedPts.Add(curr); + } + + newSections.Add(new ElkEdgeSection + { + StartPoint = fixedPts[0], + EndPoint = fixedPts[^1], + BendPoints = fixedPts.Skip(1).Take(fixedPts.Count - 2).ToArray(), + }); + } + + result[i] = anyFixed + ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } + : edge; + } + return result; + } + + private static ElkRoutedEdge[] SeparateOverlappingEdgeSegments(ElkRoutedEdge[] edges) + { + const double minSpacing = 8d; + var hSegments = new List<(int EdgeIndex, int SectionIndex, int SegIndex, double Y, double MinX, double MaxX)>(); + + for (var ei = 0; ei < edges.Length; ei++) + { + var edge = edges[ei]; + for (var si = 0; si < edge.Sections.Count; si++) + { + var section = edge.Sections.ElementAt(si); + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + for (var pi = 0; pi < pts.Count - 1; pi++) + { + if (Math.Abs(pts[pi].Y - pts[pi + 1].Y) < 1d) + { + hSegments.Add((ei, si, pi, pts[pi].Y, Math.Min(pts[pi].X, pts[pi + 1].X), Math.Max(pts[pi].X, pts[pi + 1].X))); + } + } + } + } + + var result = edges.ToArray(); + var shifts = new Dictionary<(int, int), double>(); + + hSegments.Sort((a, b) => a.Y.CompareTo(b.Y)); + for (var i = 0; i < hSegments.Count; i++) + { + var group = new List<(int EdgeIndex, int SectionIndex, int SegIndex, double Y, double MinX, double MaxX)> { hSegments[i] }; + for (var j = i + 1; j < hSegments.Count; j++) + { + if (hSegments[j].Y - hSegments[i].Y > minSpacing) break; + if (hSegments[j].EdgeIndex == hSegments[i].EdgeIndex) continue; + var overlapStart = Math.Max(hSegments[i].MinX, hSegments[j].MinX); + var overlapEnd = Math.Min(hSegments[i].MaxX, hSegments[j].MaxX); + if (overlapEnd - overlapStart > 20d) + { + group.Add(hSegments[j]); + } + } + + if (group.Count < 2) continue; + + var medianY = group.Average(s => s.Y); + var totalSpan = (group.Count - 1) * minSpacing; + for (var k = 0; k < group.Count; k++) + { + var newY = medianY - totalSpan / 2d + k * minSpacing; + var key = (group[k].EdgeIndex, group[k].SegIndex); + if (!shifts.ContainsKey(key)) + { + shifts[key] = newY - group[k].Y; + } + } + } + + if (shifts.Count == 0) return edges; + + foreach (var ((edgeIdx, segIdx), shift) in shifts) + { + if (Math.Abs(shift) < 1d) continue; + var edge = result[edgeIdx]; + var section = edge.Sections.First(); + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + + if (segIdx + 1 < pts.Count) + { + pts[segIdx] = new ElkPoint { X = pts[segIdx].X, Y = pts[segIdx].Y + shift }; + pts[segIdx + 1] = new ElkPoint { X = pts[segIdx + 1].X, Y = pts[segIdx + 1].Y + shift }; + } + + result[edgeIdx] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = [new ElkEdgeSection + { + StartPoint = pts[0], + EndPoint = pts[^1], + BendPoints = pts.Skip(1).Take(pts.Count - 2).ToArray(), + }], + }; + } + + return result; + } + + private static ElkRoutedEdge[] TightenOuterCorridors( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (nodes.Length == 0) return edges; + + var graphMinY = nodes.Min(n => n.Y); + var graphMaxY = nodes.Max(n => n.Y + n.Height); + const double minMargin = 12d; + const double laneGap = 8d; + + var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>(); + for (var i = 0; i < edges.Length; i++) + { + foreach (var section in edges[i].Sections) + { + var pts = new List { section.StartPoint }; + pts.AddRange(section.BendPoints); + pts.Add(section.EndPoint); + foreach (var pt in pts) + { + if (pt.Y < graphMinY - 8d) + { + outerEdges.Add((i, pt.Y, true)); + break; + } + if (pt.Y > graphMaxY + 8d) + { + outerEdges.Add((i, pt.Y, false)); + break; + } + } + } + } + + if (outerEdges.Count == 0) return edges; + + var aboveEdges = outerEdges.Where(e => e.IsAbove).OrderBy(e => e.CorridorY).ToArray(); + var belowEdges = outerEdges.Where(e => !e.IsAbove).OrderByDescending(e => e.CorridorY).ToArray(); + + var result = edges.ToArray(); + var shifts = new Dictionary(); + + for (var lane = 0; lane < aboveEdges.Length; lane++) + { + var targetY = graphMinY - minMargin - (lane * laneGap); + var currentY = aboveEdges[lane].CorridorY; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + shifts[aboveEdges[lane].Index] = shift; + } + } + + for (var lane = 0; lane < belowEdges.Length; lane++) + { + var targetY = graphMaxY + minMargin + (lane * laneGap); + var currentY = belowEdges[lane].CorridorY; + var shift = targetY - currentY; + if (Math.Abs(shift) > 2d) + { + shifts[belowEdges[lane].Index] = shift; + } + } + + foreach (var (edgeIndex, shift) in shifts) + { + var edge = result[edgeIndex]; + var boundary = shift > 0 ? graphMaxY : graphMinY; + var newSections = new List(); + foreach (var section in edge.Sections) + { + var newBendPoints = section.BendPoints.Select(bp => + { + if ((shift < 0 && bp.Y < graphMinY - 4d) || (shift > 0 && bp.Y > graphMaxY + 4d)) + { + return new ElkPoint { X = bp.X, Y = bp.Y + shift }; + } + return bp; + }).ToArray(); + + newSections.Add(new ElkEdgeSection + { + StartPoint = section.StartPoint, + EndPoint = section.EndPoint, + BendPoints = newBendPoints, + }); + } + + result[edgeIndex] = new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Label = edge.Label, + Sections = newSections, + }; + } + + return result; + } + + private static double Clamp(double value, double minimum, double maximum) + { + return Math.Min(Math.Max(value, minimum), maximum); + } + + private enum EdgeRouteMode + { + Direct = 0, + BackwardOuter = 1, + SinkOuter = 2, + SinkOuterTop = 3, + } + + private readonly record struct GraphBounds(double MinX, double MinY, double MaxX, double MaxY); + + private readonly record struct LayerBoundary(double MinX, double MaxX, double MinY, double MaxY); + + private readonly record struct EdgeChannel( + EdgeRouteMode RouteMode, + int BackwardLane, + int BackwardTargetIndex, + int BackwardTargetCount, + int ForwardIndex, + int ForwardCount, + int TargetIncomingIndex, + int TargetIncomingCount, + int SinkBandIndex, + int SinkBandCount, + double SharedOuterX, + double PreferredOuterY, + bool UseSourceCollector, + double PreferredDirectChannelX); + + private readonly record struct DirectChannelCandidate( + string EdgeId, + string GapKey, + double GapMinX, + double GapMaxX, + int FamilyPriority, + double SourceCenterY, + double TargetCenterY, + double TargetX); + + private static string NormalizeSide(string? side, ElkLayoutDirection direction) + { + if (string.IsNullOrWhiteSpace(side)) + { + return direction == ElkLayoutDirection.LeftToRight ? "EAST" : "SOUTH"; + } + + return side.Trim().ToUpperInvariant() switch + { + "LEFT" => "WEST", + "RIGHT" => "EAST", + "TOP" => "NORTH", + "BOTTOM" => "SOUTH", + var normalized => normalized, + }; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index e80000a14..8c7e9caf4 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -11,10 +11,10 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine cancellationToken.ThrowIfCancellationRequested(); options ??= new ElkLayoutOptions(); - ValidateGraph(graph); + ElkGraphValidator.ValidateGraph(graph); var nodesById = graph.Nodes.ToDictionary(x => x.Id, StringComparer.Ordinal); - var (inputOrder, backEdgeIds) = BuildTraversalInputOrder(graph.Nodes, graph.Edges, nodesById); + var (inputOrder, backEdgeIds) = ElkLayerAssignment.BuildTraversalInputOrder(graph.Nodes, graph.Edges, nodesById); var outgoing = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); var incomingNodeIds = graph.Nodes.ToDictionary(x => x.Id, _ => new List(), StringComparer.Ordinal); @@ -27,9 +27,9 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine outgoingNodeIds[edge.SourceNodeId].Add(edge.TargetNodeId); } - var layersByNodeId = AssignLayersByInputOrder(graph.Nodes, outgoing, inputOrder, backEdgeIds); + var layersByNodeId = ElkLayerAssignment.AssignLayersByInputOrder(graph.Nodes, outgoing, inputOrder, backEdgeIds); - var dummyResult = InsertDummyNodes(graph.Nodes, graph.Edges, layersByNodeId, inputOrder, backEdgeIds); + var dummyResult = ElkLayerAssignment.InsertDummyNodes(graph.Nodes, graph.Edges, layersByNodeId, inputOrder, backEdgeIds); var allNodes = dummyResult.AllNodes; var allEdges = dummyResult.AllEdges; var augmentedNodesById = allNodes.ToDictionary(x => x.Id, StringComparer.Ordinal); @@ -42,13 +42,13 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine augmentedOutgoing[edge.SourceNodeId].Add(edge.TargetNodeId); } - var orderingIterations = ResolveOrderingIterationCount(options, allEdges.Count, layersByNodeId.Count); + var orderingIterations = ElkNodePlacement.ResolveOrderingIterationCount(options, allEdges.Count, layersByNodeId.Count); var layers = allNodes .GroupBy(x => dummyResult.AugmentedLayers[x.Id]) .OrderBy(x => x.Key) .Select(x => x.OrderBy(node => augmentedInputOrder[node.Id]).ToArray()) .ToArray(); - layers = OptimizeLayerOrdering(layers, augmentedIncoming, augmentedOutgoing, augmentedInputOrder, orderingIterations); + layers = ElkNodeOrdering.OptimizeLayerOrdering(layers, augmentedIncoming, augmentedOutgoing, augmentedInputOrder, orderingIterations); for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) { var realNodes = layers[layerIndex].Where(n => !dummyResult.DummyNodeIds.Contains(n.Id)).ToArray(); @@ -56,312 +56,32 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine layers[layerIndex] = realNodes.Concat(dummyNodes).ToArray(); } - var placementIterations = ResolvePlacementIterationCount(options, allNodes.Count, layers.Length); + var placementIterations = ElkNodePlacement.ResolvePlacementIterationCount(options, allNodes.Count, layers.Length); var positionedNodes = new Dictionary(StringComparer.Ordinal); - var globalNodeHeight = graph.Nodes.Max(x => x.Height); var globalNodeWidth = graph.Nodes.Max(x => x.Width); var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, allEdges.Count - 15) * 0.02d)); var adaptiveNodeSpacing = options.NodeSpacing * edgeDensityFactor; - var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)); if (options.Direction == ElkLayoutDirection.LeftToRight) { - var layerXPositions = new double[layers.Length]; - var currentX = 0d; - for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) - { - layerXPositions[layerIndex] = currentX; - currentX += layers[layerIndex].Max(x => x.Width) + adaptiveLayerSpacing; - } - - var slotHeight = globalNodeHeight; - for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) - { - var layer = layers[layerIndex]; - var desiredY = new double[layer.Length]; - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - var centers = new List(); - foreach (var srcId in augmentedIncoming[node.Id]) - { - if (positionedNodes.TryGetValue(srcId, out var srcPos)) - { - centers.Add(srcPos.Y + (srcPos.Height / 2d)); - } - } - - if (centers.Count > 0) - { - centers.Sort(); - var mid = centers.Count / 2; - var median = centers.Count % 2 == 1 - ? centers[mid] - : (centers[mid - 1] + centers[mid]) / 2d; - desiredY[nodeIndex] = median - (node.Height / 2d); - } - else - { - desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing); - } - } - - for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) - { - var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); - var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); - var pairSpacing = (prevIsDummy && currIsDummy) ? 2d - : (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) - : adaptiveNodeSpacing; - var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing; - if (desiredY[nodeIndex] < minY) - { - desiredY[nodeIndex] = minY; - } - } - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( - layer[nodeIndex], layerXPositions[layerIndex], desiredY[nodeIndex], options.Direction); - } - } - - var minNodeY = positionedNodes.Values.Min(n => n.Y); - if (minNodeY < -0.01d) - { - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - var pos = positionedNodes[nodeId]; - positionedNodes[nodeId] = CreatePositionedNode( - augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); - } - } - - RefineHorizontalPlacement( - positionedNodes, - layers, - incomingNodeIds, - outgoingNodeIds, - augmentedNodesById, - options.NodeSpacing, - placementIterations, - options.Direction); - - SnapOriginalPrimaryAxes( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - incomingNodeIds, - outgoingNodeIds, - nodesById, - options.NodeSpacing, - options.Direction); - - CompactTowardIncomingFlow( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - incomingNodeIds, - nodesById, - options.NodeSpacing, - options.Direction); - - SnapOriginalPrimaryAxes( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - incomingNodeIds, - outgoingNodeIds, - nodesById, - options.NodeSpacing, - options.Direction); - - AlignDummyNodesToFlow( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - augmentedIncoming, - augmentedOutgoing, - augmentedNodesById, - options.Direction); - - CenterMultiIncomingNodes( - positionedNodes, - incomingNodeIds, - nodesById, - options.Direction); - - PropagateSuccessorPositionBackward( - positionedNodes, - outgoingNodeIds, - nodesById, - options.Direction); - - minNodeY = positionedNodes.Values.Min(n => n.Y); - if (minNodeY < -0.01d) - { - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - var pos = positionedNodes[nodeId]; - positionedNodes[nodeId] = CreatePositionedNode( - augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); - } - } + ElkSharpLayoutInitialPlacement.PlaceNodesLeftToRight( + positionedNodes, layers, dummyResult, augmentedIncoming, augmentedOutgoing, + augmentedNodesById, incomingNodeIds, outgoingNodeIds, nodesById, + adaptiveNodeSpacing, options, placementIterations); } else { - var layerYPositions = new double[layers.Length]; - var currentY = 0d; - for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) - { - layerYPositions[layerIndex] = currentY; - currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing; - } - - var slotWidth = globalNodeWidth; - for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) - { - var layer = layers[layerIndex]; - var desiredX = new double[layer.Length]; - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - var centers = new List(); - foreach (var srcId in augmentedIncoming[node.Id]) - { - if (positionedNodes.TryGetValue(srcId, out var srcPos)) - { - centers.Add(srcPos.X + (srcPos.Width / 2d)); - } - } - - if (centers.Count > 0) - { - centers.Sort(); - var mid = centers.Count / 2; - var median = centers.Count % 2 == 1 - ? centers[mid] - : (centers[mid - 1] + centers[mid]) / 2d; - desiredX[nodeIndex] = median - (node.Width / 2d); - } - else - { - desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing); - } - } - - for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) - { - var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); - var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); - var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d - : (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) - : adaptiveNodeSpacing; - var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX; - if (desiredX[nodeIndex] < minX) - { - desiredX[nodeIndex] = minX; - } - } - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( - layer[nodeIndex], desiredX[nodeIndex], layerYPositions[layerIndex], options.Direction); - } - } - - var minNodeX = positionedNodes.Values.Min(n => n.X); - if (minNodeX < -0.01d) - { - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - var pos = positionedNodes[nodeId]; - positionedNodes[nodeId] = CreatePositionedNode( - augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); - } - } - - RefineVerticalPlacement( - positionedNodes, - layers, - incomingNodeIds, - outgoingNodeIds, - augmentedNodesById, - options.NodeSpacing, - placementIterations, - options.Direction); - - SnapOriginalPrimaryAxes( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - incomingNodeIds, - outgoingNodeIds, - nodesById, - options.NodeSpacing, - options.Direction); - - CompactTowardIncomingFlow( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - incomingNodeIds, - nodesById, - options.NodeSpacing, - options.Direction); - - SnapOriginalPrimaryAxes( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - incomingNodeIds, - outgoingNodeIds, - nodesById, - options.NodeSpacing, - options.Direction); - - AlignDummyNodesToFlow( - positionedNodes, - layers, - dummyResult.DummyNodeIds, - augmentedIncoming, - augmentedOutgoing, - augmentedNodesById, - options.Direction); - - CenterMultiIncomingNodes( - positionedNodes, - incomingNodeIds, - nodesById, - options.Direction); - - PropagateSuccessorPositionBackward( - positionedNodes, - outgoingNodeIds, - nodesById, - options.Direction); - - minNodeX = positionedNodes.Values.Min(n => n.X); - if (minNodeX < -0.01d) - { - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - var pos = positionedNodes[nodeId]; - positionedNodes[nodeId] = CreatePositionedNode( - augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); - } - } + ElkSharpLayoutInitialPlacement.PlaceNodesTopToBottom( + positionedNodes, layers, dummyResult, augmentedIncoming, augmentedOutgoing, + augmentedNodesById, incomingNodeIds, outgoingNodeIds, nodesById, + globalNodeWidth, adaptiveNodeSpacing, options, placementIterations); } - var graphBounds = ComputeGraphBounds(positionedNodes.Values + var graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); - var layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); - var edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); - var reconstructedEdges = ReconstructDummyEdges( + var layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + var edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + var reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges( graph.Edges, dummyResult, positionedNodes, @@ -373,12 +93,12 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine var routedEdges = graph.Edges .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var routed) ? routed - : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) .ToArray(); for (var gutterPass = 0; gutterPass < 3; gutterPass++) { - if (!ExpandVerticalCorridorGutters( + if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters( positionedNodes, routedEdges, dummyResult.AugmentedLayers, @@ -389,11 +109,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine break; } - graphBounds = ComputeGraphBounds(positionedNodes.Values + graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); - layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); - edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); - reconstructedEdges = ReconstructDummyEdges( + layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges( graph.Edges, dummyResult, positionedNodes, @@ -405,14 +125,14 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine routedEdges = graph.Edges .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) ? rerouted - : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) .ToArray(); } for (var compactPass = 0; compactPass < 2; compactPass++) { - if (!CompactSparseVerticalCorridorGutters( + if (!ElkEdgeChannelGutters.CompactSparseVerticalCorridorGutters( positionedNodes, routedEdges, dummyResult.AugmentedLayers, @@ -423,11 +143,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine break; } - graphBounds = ComputeGraphBounds(positionedNodes.Values + graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); - layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); - edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); - reconstructedEdges = ReconstructDummyEdges( + layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges( graph.Edges, dummyResult, positionedNodes, @@ -439,11 +159,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine routedEdges = graph.Edges .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) ? rerouted - : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) .ToArray(); - if (!ExpandVerticalCorridorGutters( + if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters( positionedNodes, routedEdges, dummyResult.AugmentedLayers, @@ -454,11 +174,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine continue; } - graphBounds = ComputeGraphBounds(positionedNodes.Values + graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); - layerBoundariesByNodeId = BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); - edgeChannels = ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); - reconstructedEdges = ReconstructDummyEdges( + layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges( graph.Edges, dummyResult, positionedNodes, @@ -470,7 +190,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine routedEdges = graph.Edges .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) ? rerouted - : RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) .ToArray(); } @@ -480,12 +200,17 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine .OrderBy(x => inputOrder.GetValueOrDefault(x.Id, int.MaxValue)) .ToArray(); - routedEdges = AvoidNodeCrossings(routedEdges, finalNodes, options.Direction); - - routedEdges = DistributeOverlappingPorts(routedEdges, finalNodes, options.Direction); - routedEdges = SimplifyEdgePaths(routedEdges, finalNodes); - routedEdges = SnapAnchorsToNodeBoundary(routedEdges, finalNodes); - routedEdges = TightenOuterCorridors(routedEdges, finalNodes); + // Post-processing pipeline (5 generic passes, no node-specific logic): + // 1. Project endpoints onto actual node shape boundaries (diamond/hexagon/rectangle) + routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalNodes); + // 2. Reroute any edge crossing node bounding boxes (including diagonals from shape projection) + routedEdges = ElkEdgePostProcessor.AvoidNodeCrossings(routedEdges, finalNodes, options.Direction); + // 3. Convert any remaining diagonal segments to orthogonal L-corners + routedEdges = ElkEdgePostProcessor.EliminateDiagonalSegments(routedEdges, finalNodes); + // 4. Simplify: remove collinear/duplicate points, try L-shape shortcuts + routedEdges = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(routedEdges, finalNodes); + // 5. Compress outer corridor distances + routedEdges = ElkEdgePostProcessorSimplify.TightenOuterCorridors(routedEdges, finalNodes); return Task.FromResult(new ElkLayoutResult { @@ -494,4123 +219,4 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine Edges = routedEdges, }); } - - private static void ValidateGraph(ElkGraph graph) - { - if (graph.Nodes.Count == 0) - { - throw new InvalidOperationException("ElkSharp requires at least one node."); - } - - var duplicateNodeId = graph.Nodes - .GroupBy(x => x.Id, StringComparer.Ordinal) - .FirstOrDefault(x => x.Count() > 1); - if (duplicateNodeId is not null) - { - throw new InvalidOperationException($"ElkSharp requires unique node ids. Duplicate '{duplicateNodeId.Key}' was found."); - } - - if (graph.Nodes.Any(x => !string.IsNullOrWhiteSpace(x.ParentNodeId))) - { - throw new NotSupportedException("ElkSharp currently supports flat graphs only. Compound nodes are not implemented in this spike."); - } - - var nodeIds = graph.Nodes.Select(x => x.Id).ToHashSet(StringComparer.Ordinal); - foreach (var edge in graph.Edges) - { - if (!nodeIds.Contains(edge.SourceNodeId) || !nodeIds.Contains(edge.TargetNodeId)) - { - throw new InvalidOperationException($"Edge '{edge.Id}' references an unknown node."); - } - } - } - - private static ElkPositionedNode CreatePositionedNode( - ElkNode node, - double x, - double y, - ElkLayoutDirection direction) - { - return new ElkPositionedNode - { - Id = node.Id, - Label = node.Label, - Kind = node.Kind, - IconKey = node.IconKey, - SemanticType = node.SemanticType, - SemanticKey = node.SemanticKey, - Route = node.Route, - TaskType = node.TaskType, - ParentNodeId = node.ParentNodeId, - X = x, - Y = y, - Width = node.Width, - Height = node.Height, - Ports = PositionPorts(node, x, y, direction), - }; - } - - private static IReadOnlyCollection PositionPorts( - ElkNode node, - double nodeX, - double nodeY, - ElkLayoutDirection direction) - { - if (node.Ports.Count == 0) - { - return []; - } - - var portsBySide = node.Ports - .GroupBy(x => NormalizeSide(x.Side, direction), StringComparer.OrdinalIgnoreCase) - .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.OrdinalIgnoreCase); - - var positionedPorts = new List(node.Ports.Count); - foreach (var sideGroup in portsBySide) - { - var side = sideGroup.Key; - var ports = sideGroup.Value; - for (var index = 0; index < ports.Length; index++) - { - positionedPorts.Add(PositionPort(nodeX, nodeY, node.Width, node.Height, ports[index], side, index, ports.Length)); - } - } - - return positionedPorts; - } - - private static ElkPositionedPort PositionPort( - double nodeX, - double nodeY, - double nodeWidth, - double nodeHeight, - ElkPort port, - string side, - int index, - int count) - { - var slot = (index + 1d) / (count + 1d); - var x = nodeX; - var y = nodeY; - - switch (side) - { - case "EAST": - x = nodeX + nodeWidth - (port.Width / 2d); - y = nodeY + (nodeHeight * slot) - (port.Height / 2d); - break; - case "WEST": - x = nodeX - (port.Width / 2d); - y = nodeY + (nodeHeight * slot) - (port.Height / 2d); - break; - case "NORTH": - x = nodeX + (nodeWidth * slot) - (port.Width / 2d); - y = nodeY - (port.Height / 2d); - break; - default: - x = nodeX + (nodeWidth * slot) - (port.Width / 2d); - y = nodeY + nodeHeight - (port.Height / 2d); - break; - } - - return new ElkPositionedPort - { - Id = port.Id, - Side = side, - X = x, - Y = y, - Width = port.Width, - Height = port.Height, - }; - } - - private static ElkRoutedEdge RouteEdge( - ElkEdge edge, - IReadOnlyDictionary nodesById, - IReadOnlyDictionary positionedNodes, - ElkLayoutDirection direction, - GraphBounds graphBounds, - EdgeChannel channel, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var sourceNode = positionedNodes[edge.SourceNodeId]; - var targetNode = positionedNodes[edge.TargetNodeId]; - - var (sourceSide, targetSide) = ResolveRouteSides(sourceNode, targetNode, direction); - var sourcePoint = ResolveAnchorPoint(sourceNode, targetNode, edge.SourcePortId, direction, sourceSide); - var targetPoint = ResolveAnchorPoint(targetNode, sourceNode, edge.TargetPortId, direction, targetSide); - - if (string.IsNullOrWhiteSpace(edge.SourcePortId) - && string.IsNullOrWhiteSpace(edge.TargetPortId) - && channel.RouteMode == EdgeRouteMode.Direct) - { - (sourcePoint, targetPoint) = ResolveStraightChainAnchors( - sourceNode, - targetNode, - sourcePoint, - targetPoint, - sourceSide, - targetSide, - channel, - direction); - } - - if (string.IsNullOrWhiteSpace(edge.TargetPortId) - && channel.TargetIncomingCount > 1 - && direction == ElkLayoutDirection.LeftToRight - && targetPoint.X >= sourcePoint.X) - { - var insetY = Math.Min(16d, (targetNode.Height - 12d) / Math.Max(1, channel.TargetIncomingCount)); - var totalInset = (channel.TargetIncomingCount - 1) * insetY; - var adjustedY = (targetNode.Y + (targetNode.Height / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetY); - adjustedY = Clamp(adjustedY, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d); - targetPoint = new ElkPoint { X = targetPoint.X, Y = adjustedY }; - } - - if (string.IsNullOrWhiteSpace(edge.TargetPortId) - && channel.TargetIncomingCount > 1 - && direction == ElkLayoutDirection.TopToBottom - && targetPoint.Y >= sourcePoint.Y) - { - var insetX = Math.Min(16d, (targetNode.Width - 12d) / Math.Max(1, channel.TargetIncomingCount)); - var totalInset = (channel.TargetIncomingCount - 1) * insetX; - var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalInset / 2d) + (channel.TargetIncomingIndex * insetX); - adjustedX = Clamp(adjustedX, targetNode.X + 6d, targetNode.X + targetNode.Width - 6d); - targetPoint = new ElkPoint { X = adjustedX, Y = targetPoint.Y }; - } - - if (string.IsNullOrWhiteSpace(edge.TargetPortId) - && channel.BackwardTargetCount > 1 - && targetPoint.X < sourcePoint.X - && direction == ElkLayoutDirection.LeftToRight) - { - var spread = Math.Min(24d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); - var totalSpread = (channel.BackwardTargetCount - 1) * spread; - var adjustedX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); - adjustedX = Clamp(adjustedX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); - targetPoint = new ElkPoint { X = adjustedX, Y = targetNode.Y }; - } - - var bendPoints = direction == ElkLayoutDirection.LeftToRight - ? BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId) - : BuildVerticalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId); - - return new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = - [ - new ElkEdgeSection - { - StartPoint = sourcePoint, - EndPoint = targetPoint, - BendPoints = bendPoints, - }, - ], - }; - } - - private static ElkPoint ResolveAnchorPoint( - ElkPositionedNode node, - ElkPositionedNode otherNode, - string? portId, - ElkLayoutDirection direction, - string? forcedSide = null) - { - if (!string.IsNullOrWhiteSpace(portId)) - { - var port = node.Ports.FirstOrDefault(x => string.Equals(x.Id, portId, StringComparison.Ordinal)); - if (port is not null) - { - return new ElkPoint - { - X = port.X + (port.Width / 2d), - Y = port.Y + (port.Height / 2d), - }; - } - } - - var nodeCenterX = node.X + (node.Width / 2d); - var nodeCenterY = node.Y + (node.Height / 2d); - var otherCenterX = otherNode.X + (otherNode.Width / 2d); - var otherCenterY = otherNode.Y + (otherNode.Height / 2d); - - if (Math.Abs(otherCenterX - nodeCenterX) < 0.001d - && Math.Abs(otherCenterY - nodeCenterY) < 0.001d) - { - return new ElkPoint - { - X = nodeCenterX, - Y = nodeCenterY, - }; - } - - return ResolvePreferredAnchorPoint(node, otherCenterX, otherCenterY, forcedSide, direction); - } - - private static (ElkPoint SourcePoint, ElkPoint TargetPoint) ResolveStraightChainAnchors( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint sourcePoint, - ElkPoint targetPoint, - string sourceSide, - string targetSide, - EdgeChannel channel, - ElkLayoutDirection direction) - { - if (direction == ElkLayoutDirection.LeftToRight) - { - if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.X < sourcePoint.X) - { - return (sourcePoint, targetPoint); - } - - var sharedY = sourceNode.Y + (sourceNode.Height / 2d); - return ( - ResolvePreferredAnchorPoint(sourceNode, targetNode.X, sharedY, sourceSide, direction), - ResolvePreferredAnchorPoint(targetNode, sourceNode.X + sourceNode.Width, sharedY, targetSide, direction)); - } - - if (channel.ForwardCount != 1 || channel.TargetIncomingCount != 1 || targetPoint.Y < sourcePoint.Y) - { - return (sourcePoint, targetPoint); - } - - var sharedX = sourceNode.X + (sourceNode.Width / 2d); - return ( - ResolvePreferredAnchorPoint(sourceNode, sharedX, targetNode.Y, sourceSide, direction), - ResolvePreferredAnchorPoint(targetNode, sharedX, sourceNode.Y + sourceNode.Height, targetSide, direction)); - } - - private static IReadOnlyCollection BuildHorizontalBendPoints( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - GraphBounds graphBounds, - EdgeChannel channel, - IReadOnlyDictionary layerBoundariesByNodeId) - { - if (channel.RouteMode == EdgeRouteMode.SinkOuter) - { - return BuildHorizontalSinkBendPoints( - sourceNode, - targetNode, - startPoint, - endPoint, - graphBounds, - channel, - layerBoundariesByNodeId); - } - - if (channel.RouteMode == EdgeRouteMode.SinkOuterTop) - { - return BuildHorizontalTopSinkBendPoints( - sourceNode, - targetNode, - startPoint, - endPoint, - graphBounds, - channel, - layerBoundariesByNodeId); - } - - if (Math.Abs(endPoint.Y - startPoint.Y) <= 6d && endPoint.X >= startPoint.X) - { - return []; - } - - if (channel.RouteMode == EdgeRouteMode.BackwardOuter || endPoint.X < startPoint.X) - { - return BuildHorizontalBackwardBendPoints( - sourceNode, - targetNode, - startPoint, - endPoint, - graphBounds, - channel, - layerBoundariesByNodeId); - } - - var baseChannelX = ResolveForwardChannelX(sourceNode, targetNode, startPoint, endPoint, channel); - - var channelX = Clamp(baseChannelX, startPoint.X + 12d, endPoint.X - 12d); - - return NormalizeBendPoints( - new ElkPoint { X = channelX, Y = startPoint.Y }, - new ElkPoint { X = channelX, Y = endPoint.Y }); - } - - private static double ResolveForwardChannelX( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - EdgeChannel channel) - { - if (!double.IsNaN(channel.PreferredDirectChannelX)) - { - return channel.PreferredDirectChannelX; - } - - if (ShouldPreferSourceLocalForwardDrop(sourceNode, targetNode, startPoint, endPoint, channel)) - { - var sourceLocalBase = Math.Max( - startPoint.X + 24d, - sourceNode.X + sourceNode.Width + 36d); - return sourceLocalBase + (channel.ForwardIndex * 36d); - } - - var baseChannelX = (sourceNode.X + sourceNode.Width + targetNode.X) / 2d; - if (channel.ForwardCount > 1) - { - var totalWidth = (channel.ForwardCount - 1) * 16d; - var offset = (channel.ForwardIndex * 16d) - (totalWidth / 2d); - baseChannelX += offset; - } - - return baseChannelX; - } - - private static double ResolveForwardSourceExitX( - ElkPositionedNode sourceNode, - ElkPoint startPoint, - EdgeChannel channel, - double baseOffset, - double spread) - { - var sourceLocalBase = Math.Max( - startPoint.X + 24d, - sourceNode.X + sourceNode.Width + baseOffset); - if (channel.ForwardCount <= 1) - { - return sourceLocalBase; - } - - return sourceLocalBase + (channel.ForwardIndex * spread); - } - - private static bool ShouldPreferSourceLocalForwardDrop( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - EdgeChannel channel) - { - if (channel.RouteMode != EdgeRouteMode.Direct || channel.ForwardCount <= 1) - { - return false; - } - - var verticalSpan = Math.Abs(endPoint.Y - startPoint.Y); - if (verticalSpan < 120d) - { - return false; - } - - var horizontalSpan = endPoint.X - startPoint.X; - if (horizontalSpan < 180d) - { - return false; - } - - return string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase) - || string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase) - || verticalSpan > horizontalSpan * 0.45d; - } - - private static IReadOnlyCollection BuildVerticalBendPoints( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - GraphBounds graphBounds, - EdgeChannel channel, - IReadOnlyDictionary layerBoundariesByNodeId) - { - if (Math.Abs(endPoint.X - startPoint.X) <= 6d && endPoint.Y >= startPoint.Y) - { - return []; - } - - if (endPoint.Y < startPoint.Y) - { - var lane = Math.Max(0, channel.BackwardLane); - var outerX = graphBounds.MinX - 48d - (lane * 24d); - - if (channel.BackwardTargetCount > 1) - { - var spread = Math.Min(18d, (targetNode.Width - 16d) / Math.Max(1, channel.BackwardTargetCount)); - var totalSpread = (channel.BackwardTargetCount - 1) * spread; - var adjustedEndX = (targetNode.X + (targetNode.Width / 2d)) - (totalSpread / 2d) + (channel.BackwardTargetIndex * spread); - adjustedEndX = Clamp(adjustedEndX, targetNode.X + 8d, targetNode.X + targetNode.Width - 8d); - return NormalizeBendPoints( - new ElkPoint { X = outerX, Y = startPoint.Y }, - new ElkPoint { X = outerX, Y = endPoint.Y }, - new ElkPoint { X = adjustedEndX, Y = endPoint.Y }); - } - - return NormalizeBendPoints( - new ElkPoint { X = outerX, Y = startPoint.Y }, - new ElkPoint { X = outerX, Y = endPoint.Y }); - } - - var baseChannelY = (sourceNode.Y + sourceNode.Height + targetNode.Y) / 2d; - if (channel.ForwardCount > 1) - { - var totalHeight = (channel.ForwardCount - 1) * 16d; - var offset = (channel.ForwardIndex * 16d) - (totalHeight / 2d); - baseChannelY += offset; - } - - var channelY = Clamp(baseChannelY, startPoint.Y + 12d, endPoint.Y - 12d); - - return NormalizeBendPoints( - new ElkPoint { X = startPoint.X, Y = channelY }, - new ElkPoint { X = endPoint.X, Y = channelY }); - } - - private static IReadOnlyCollection BuildHorizontalBackwardBendPoints( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - GraphBounds graphBounds, - EdgeChannel channel, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var lane = Math.Max(0, channel.BackwardLane); - var isLowerCorridor = !double.IsNaN(channel.PreferredOuterY) - && channel.PreferredOuterY > Math.Max(startPoint.Y, endPoint.Y) + 4d; - var outerY = double.IsNaN(channel.PreferredOuterY) - ? graphBounds.MinY - 56d - (lane * 28d) - : isLowerCorridor - ? channel.PreferredOuterY + (lane * 24d) - : channel.PreferredOuterY - (lane * 24d); - var sourceBoundary = ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); - var sourceExitX = channel.UseSourceCollector - ? startPoint.X - : channel.SharedOuterX > 0d - ? Math.Max(startPoint.X + 18d, channel.SharedOuterX) - : Math.Max(startPoint.X + 18d, sourceBoundary.MaxX + 28d); - var approachX = endPoint.X; - - return NormalizeBendPoints( - new ElkPoint { X = startPoint.X, Y = outerY }, - new ElkPoint { X = sourceExitX, Y = outerY }, - new ElkPoint { X = approachX, Y = outerY }); - } - - private static IReadOnlyCollection BuildHorizontalSinkBendPoints( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - GraphBounds graphBounds, - EdgeChannel channel, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var targetBoundary = ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); - var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 36d, 36d); - var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); - var outerY = graphBounds.MaxY + 32d + ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex)); - - return NormalizeBendPoints( - new ElkPoint { X = sourceExitX, Y = startPoint.Y }, - new ElkPoint { X = sourceExitX, Y = outerY }, - new ElkPoint { X = targetApproachX, Y = outerY }, - new ElkPoint { X = targetApproachX, Y = endPoint.Y }); - } - - private static IReadOnlyCollection BuildHorizontalTopSinkBendPoints( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkPoint startPoint, - ElkPoint endPoint, - GraphBounds graphBounds, - EdgeChannel channel, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var sourceBoundary = ResolveLayerBoundary(sourceNode.Id, layerBoundariesByNodeId, sourceNode); - var targetBoundary = ResolveLayerBoundary(targetNode.Id, layerBoundariesByNodeId, targetNode); - var sourceExitX = ResolveForwardSourceExitX(sourceNode, startPoint, channel, 40d, 40d); - var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); - var outerY = double.IsNaN(channel.PreferredOuterY) - ? graphBounds.MinY - 56d - ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 36d, 28d) - : channel.PreferredOuterY + ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d); - - return NormalizeBendPoints( - new ElkPoint { X = sourceExitX, Y = startPoint.Y }, - new ElkPoint { X = sourceExitX, Y = outerY }, - new ElkPoint { X = targetApproachX, Y = outerY }, - new ElkPoint { X = targetApproachX, Y = endPoint.Y }); - } - - private static ElkPoint ResolvePreferredAnchorPoint( - ElkPositionedNode node, - double targetX, - double targetY, - string? forcedSide, - ElkLayoutDirection direction) - { - var nodeCenterX = node.X + (node.Width / 2d); - var nodeCenterY = node.Y + (node.Height / 2d); - var deltaX = targetX - nodeCenterX; - var deltaY = targetY - nodeCenterY; - var insetX = Math.Min(18d, node.Width / 4d); - var insetY = Math.Min(18d, node.Height / 4d); - - var preferredSide = forcedSide; - if (string.IsNullOrWhiteSpace(preferredSide)) - { - preferredSide = direction == ElkLayoutDirection.LeftToRight - ? (Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d - ? (deltaX >= 0d ? "EAST" : "WEST") - : (deltaY >= 0d ? "SOUTH" : "NORTH")) - : (Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d - ? (deltaY >= 0d ? "SOUTH" : "NORTH") - : (deltaX >= 0d ? "EAST" : "WEST")); - } - - var preferredTargetX = preferredSide switch - { - "EAST" => node.X + node.Width + 256d, - "WEST" => node.X - 256d, - _ => Clamp(targetX, node.X + insetX, node.X + node.Width - insetX), - }; - var preferredTargetY = preferredSide switch - { - "SOUTH" => node.Y + node.Height + 256d, - "NORTH" => node.Y - 256d, - _ => Clamp(targetY, node.Y + insetY, node.Y + node.Height - insetY), - }; - - var adjustedDeltaX = preferredTargetX - nodeCenterX; - var adjustedDeltaY = preferredTargetY - nodeCenterY; - - var candidate = new ElkPoint - { - X = preferredSide switch - { - "EAST" => node.X + node.Width, - "WEST" => node.X, - _ => Clamp(preferredTargetX, node.X + insetX, node.X + node.Width - insetX), - }, - Y = preferredSide switch - { - "SOUTH" => node.Y + node.Height, - "NORTH" => node.Y, - _ => Clamp(preferredTargetY, node.Y + insetY, node.Y + node.Height - insetY), - }, - }; - - return ResolveGatewayBoundaryPoint(node, candidate, adjustedDeltaX, adjustedDeltaY); - } - - private static Dictionary AssignLayersByInputOrder( - IReadOnlyCollection nodes, - IReadOnlyDictionary> outgoing, - IReadOnlyDictionary inputOrder, - IReadOnlySet backEdgeIds) - { - var layersByNodeId = nodes.ToDictionary(x => x.Id, _ => 0, StringComparer.Ordinal); - var orderedNodes = nodes - .OrderBy(node => inputOrder[node.Id], Comparer.Default) - .ToArray(); - - for (var iteration = 0; iteration < orderedNodes.Length; iteration++) - { - var changed = false; - foreach (var node in orderedNodes) - { - var sourceLayer = layersByNodeId[node.Id]; - foreach (var edge in outgoing[node.Id]) - { - if (backEdgeIds.Contains(edge.Id)) - { - continue; - } - - var candidateLayer = sourceLayer + 1; - if (candidateLayer <= layersByNodeId[edge.TargetNodeId]) - { - continue; - } - - layersByNodeId[edge.TargetNodeId] = candidateLayer; - changed = true; - } - } - - if (!changed) - { - break; - } - } - - for (var nodeIndex = orderedNodes.Length - 1; nodeIndex >= 0; nodeIndex--) - { - var node = orderedNodes[nodeIndex]; - var minSuccessorLayer = int.MaxValue; - foreach (var edge in outgoing[node.Id] - .Where(edge => !backEdgeIds.Contains(edge.Id))) - { - minSuccessorLayer = Math.Min(minSuccessorLayer, layersByNodeId[edge.TargetNodeId]); - } - - if (minSuccessorLayer != int.MaxValue) - { - var idealLayer = minSuccessorLayer - 1; - if (idealLayer > layersByNodeId[node.Id]) - { - layersByNodeId[node.Id] = idealLayer; - } - } - } - - return layersByNodeId; - } - - private static (string SourceSide, string TargetSide) ResolveRouteSides( - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - ElkLayoutDirection direction) - { - var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d); - var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); - var targetCenterX = targetNode.X + (targetNode.Width / 2d); - var targetCenterY = targetNode.Y + (targetNode.Height / 2d); - var deltaX = targetCenterX - sourceCenterX; - var deltaY = targetCenterY - sourceCenterY; - - if (direction == ElkLayoutDirection.LeftToRight) - { - if (Math.Abs(deltaX) >= 24d || Math.Abs(deltaX) >= Math.Abs(deltaY) * 0.35d) - { - return deltaX >= 0d - ? ("EAST", "WEST") - : ("NORTH", "NORTH"); - } - - return deltaY >= 0d - ? ("SOUTH", "NORTH") - : ("NORTH", "SOUTH"); - } - - if (Math.Abs(deltaY) >= 24d || Math.Abs(deltaY) >= Math.Abs(deltaX) * 0.35d) - { - return deltaY >= 0d - ? ("SOUTH", "NORTH") - : ("NORTH", "NORTH"); - } - - return deltaX >= 0d - ? ("EAST", "WEST") - : ("WEST", "EAST"); - } - - private static ElkNode[][] OptimizeLayerOrdering( - ElkNode[][] initialLayers, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary inputOrder, - int iterationCount = 8) - { - if (initialLayers.Length <= 2) - { - return initialLayers; - } - - var layers = initialLayers - .Select(layer => layer.ToList()) - .ToArray(); - - for (var iteration = 0; iteration < iterationCount; iteration++) - { - for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++) - { - OrderLayer(layers, layerIndex, incomingNodeIds, inputOrder); - } - - for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--) - { - OrderLayer(layers, layerIndex, outgoingNodeIds, inputOrder); - } - } - - return layers - .Select(layer => layer.ToArray()) - .ToArray(); - } - - private static int ResolveOrderingIterationCount( - ElkLayoutOptions options, - int edgeCount, - int nodeCount) - { - if (options.OrderingIterations is int explicitIterations) - { - return Math.Max(2, explicitIterations); - } - - var baseline = Math.Max(6, Math.Max(edgeCount / 4, nodeCount / 3)); - return options.Effort switch - { - ElkLayoutEffort.Draft => Math.Min(8, baseline), - ElkLayoutEffort.Balanced => Math.Min(14, Math.Max(8, baseline)), - _ => Math.Min(24, Math.Max(12, baseline + 4)), - }; - } - - private static int ResolvePlacementIterationCount( - ElkLayoutOptions options, - int nodeCount, - int layerCount) - { - if (options.PlacementIterations is int explicitIterations) - { - return Math.Max(1, explicitIterations); - } - - var baseline = Math.Max(2, Math.Max(nodeCount / 8, layerCount / 2)); - return options.Effort switch - { - ElkLayoutEffort.Draft => Math.Min(3, baseline), - ElkLayoutEffort.Balanced => Math.Min(6, Math.Max(3, baseline)), - _ => Math.Min(10, Math.Max(5, baseline + 2)), - }; - } - - private static void RefineHorizontalPlacement( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary nodesById, - double nodeSpacing, - int iterationCount, - ElkLayoutDirection direction) - { - if (iterationCount <= 0) - { - return; - } - - for (var iteration = 0; iteration < iterationCount; iteration++) - { - var layerIndices = iteration % 2 == 0 - ? Enumerable.Range(0, layers.Count) - : Enumerable.Range(0, layers.Count).Reverse(); - - foreach (var layerIndex in layerIndices) - { - var layer = layers[layerIndex]; - if (layer.Length == 0) - { - continue; - } - - var desiredY = new double[layer.Length]; - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - var preferredCenter = ResolvePreferredCenter( - node.Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: true); - desiredY[nodeIndex] = preferredCenter.HasValue - ? preferredCenter.Value - (node.Height / 2d) - : positionedNodes[node.Id].Y; - } - - for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) - { - var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + nodeSpacing; - if (desiredY[nodeIndex] < minY) - { - desiredY[nodeIndex] = minY; - } - } - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var current = positionedNodes[layer[nodeIndex].Id]; - positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( - nodesById[layer[nodeIndex].Id], - current.X, - desiredY[nodeIndex], - direction); - } - } - } - } - - private static void RefineVerticalPlacement( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary nodesById, - double nodeSpacing, - int iterationCount, - ElkLayoutDirection direction) - { - if (iterationCount <= 0) - { - return; - } - - for (var iteration = 0; iteration < iterationCount; iteration++) - { - var layerIndices = iteration % 2 == 0 - ? Enumerable.Range(0, layers.Count) - : Enumerable.Range(0, layers.Count).Reverse(); - - foreach (var layerIndex in layerIndices) - { - var layer = layers[layerIndex]; - if (layer.Length == 0) - { - continue; - } - - var desiredX = new double[layer.Length]; - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - var preferredCenter = ResolvePreferredCenter( - node.Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: false); - desiredX[nodeIndex] = preferredCenter.HasValue - ? preferredCenter.Value - (node.Width / 2d) - : positionedNodes[node.Id].X; - } - - for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) - { - var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + nodeSpacing; - if (desiredX[nodeIndex] < minX) - { - desiredX[nodeIndex] = minX; - } - } - - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var current = positionedNodes[layer[nodeIndex].Id]; - positionedNodes[layer[nodeIndex].Id] = CreatePositionedNode( - nodesById[layer[nodeIndex].Id], - desiredX[nodeIndex], - current.Y, - direction); - } - } - } - } - - private static void SnapOriginalPrimaryAxes( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlySet dummyNodeIds, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary originalNodesById, - double nodeSpacing, - ElkLayoutDirection direction) - { - for (var iteration = 0; iteration < 3; iteration++) - { - foreach (var layer in layers) - { - var actualNodes = layer - .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) - .Select(node => originalNodesById[node.Id]) - .ToArray(); - if (actualNodes.Length == 0) - { - continue; - } - - var nodeDesiredPairs = new (ElkNode Node, double Desired)[actualNodes.Length]; - for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) - { - var positioned = positionedNodes[actualNodes[nodeIndex].Id]; - var preferredCenter = ResolveOriginalPreferredCenter( - actualNodes[nodeIndex].Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: direction == ElkLayoutDirection.LeftToRight); - nodeDesiredPairs[nodeIndex] = (actualNodes[nodeIndex], preferredCenter.HasValue - ? preferredCenter.Value - ((direction == ElkLayoutDirection.LeftToRight ? positioned.Height : positioned.Width) / 2d) - : (direction == ElkLayoutDirection.LeftToRight ? positioned.Y : positioned.X)); - } - - Array.Sort(nodeDesiredPairs, (a, b) => a.Desired.CompareTo(b.Desired)); - var sortedNodes = nodeDesiredPairs.Select(p => p.Node).ToArray(); - var desiredCoordinates = nodeDesiredPairs.Select(p => p.Desired).ToArray(); - - EnforceLinearSpacing( - sortedNodes, - desiredCoordinates, - nodeSpacing, - horizontal: direction == ElkLayoutDirection.LeftToRight); - - for (var nodeIndex = 0; nodeIndex < sortedNodes.Length; nodeIndex++) - { - var current = positionedNodes[sortedNodes[nodeIndex].Id]; - positionedNodes[sortedNodes[nodeIndex].Id] = direction == ElkLayoutDirection.LeftToRight - ? CreatePositionedNode(sortedNodes[nodeIndex], current.X, desiredCoordinates[nodeIndex], direction) - : CreatePositionedNode(sortedNodes[nodeIndex], desiredCoordinates[nodeIndex], current.Y, direction); - } - } - } - } - - private static void PropagateSuccessorPositionBackward( - Dictionary positionedNodes, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary originalNodesById, - ElkLayoutDirection direction) - { - var horizontal = direction == ElkLayoutDirection.LeftToRight; - - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - if (!originalNodesById.ContainsKey(nodeId)) - { - continue; - } - - var current = positionedNodes[nodeId]; - if (!string.Equals(current.Kind, "Fork", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!outgoingNodeIds.TryGetValue(nodeId, out var forkOutgoing) || forkOutgoing.Count < 2) - { - continue; - } - - var joinSuccessor = forkOutgoing - .Select(id => positionedNodes.GetValueOrDefault(id)) - .FirstOrDefault(n => n is not null - && string.Equals(n.Kind, "Join", StringComparison.OrdinalIgnoreCase)); - if (joinSuccessor is null) - { - continue; - } - - var joinCenter = horizontal - ? joinSuccessor.Y + (joinSuccessor.Height / 2d) - : joinSuccessor.X + (joinSuccessor.Width / 2d); - var forkCenter = horizontal - ? current.Y + (current.Height / 2d) - : current.X + (current.Width / 2d); - if (Math.Abs(joinCenter - forkCenter) < 20d) - { - continue; - } - - var chainNodeIds = new List { nodeId }; - var walkId = nodeId; - for (var step = 0; step < 20; step++) - { - var predecessor = positionedNodes.Keys - .Where(id => originalNodesById.ContainsKey(id) - && outgoingNodeIds.TryGetValue(id, out var outs) && outs.Count == 1 && outs[0] == walkId) - .FirstOrDefault(); - if (predecessor is null) - { - break; - } - - chainNodeIds.Add(predecessor); - walkId = predecessor; - } - - foreach (var chainId in chainNodeIds) - { - var pos = positionedNodes[chainId]; - var orig = originalNodesById[chainId]; - if (horizontal) - { - positionedNodes[chainId] = CreatePositionedNode( - orig, pos.X, joinCenter - (pos.Height / 2d), direction); - } - else - { - positionedNodes[chainId] = CreatePositionedNode( - orig, joinCenter - (pos.Width / 2d), pos.Y, direction); - } - } - } - } - - private static void CenterMultiIncomingNodes( - Dictionary positionedNodes, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary originalNodesById, - ElkLayoutDirection direction) - { - var horizontal = direction == ElkLayoutDirection.LeftToRight; - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - if (!originalNodesById.TryGetValue(nodeId, out var originalNode)) - { - continue; - } - - var current = positionedNodes[nodeId]; - if (!string.Equals(current.Kind, "Join", StringComparison.OrdinalIgnoreCase) - && !string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count < 2) - { - continue; - } - - var centers = incomingIds - .Select(id => positionedNodes.GetValueOrDefault(id)) - .Where(n => n is not null) - .Select(n => horizontal ? n!.Y + (n.Height / 2d) : n!.X + (n.Width / 2d)) - .OrderBy(c => c) - .ToArray(); - - if (centers.Length < 2) - { - continue; - } - - double medianCenter; - if (string.Equals(current.Kind, "End", StringComparison.OrdinalIgnoreCase)) - { - medianCenter = (centers[0] + centers[^1]) / 2d; - } - else - { - var mid = centers.Length / 2; - medianCenter = centers.Length % 2 == 1 - ? centers[mid] - : (centers[mid - 1] + centers[mid]) / 2d; - } - - if (horizontal) - { - var desiredY = medianCenter - (current.Height / 2d); - positionedNodes[nodeId] = CreatePositionedNode(originalNode, current.X, desiredY, direction); - } - else - { - var desiredX = medianCenter - (current.Width / 2d); - positionedNodes[nodeId] = CreatePositionedNode(originalNode, desiredX, current.Y, direction); - } - } - } - - private static void CompactTowardIncomingFlow( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlySet dummyNodeIds, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary originalNodesById, - double nodeSpacing, - ElkLayoutDirection direction) - { - for (var iteration = 0; iteration < 3; iteration++) - { - foreach (var layer in layers) - { - var actualNodes = layer - .Where(node => !dummyNodeIds.Contains(node.Id) && originalNodesById.ContainsKey(node.Id)) - .Select(node => originalNodesById[node.Id]) - .ToArray(); - if (actualNodes.Length == 0) - { - continue; - } - - if (direction == ElkLayoutDirection.LeftToRight) - { - var previousBottom = double.NegativeInfinity; - for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) - { - var current = positionedNodes[actualNodes[nodeIndex].Id]; - var targetY = current.Y; - if (nodeIndex > 0) - { - targetY = Math.Max(targetY, previousBottom + nodeSpacing); - } - - if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) - { - var preferredCenter = ResolveIncomingPreferredCenter( - actualNodes[nodeIndex].Id, - incomingNodeIds, - positionedNodes, - horizontal: true); - if (preferredCenter.HasValue) - { - targetY = Math.Max( - nodeIndex > 0 ? previousBottom + nodeSpacing : double.NegativeInfinity, - Math.Min(current.Y, preferredCenter.Value - (current.Height / 2d))); - } - } - - positionedNodes[actualNodes[nodeIndex].Id] = CreatePositionedNode( - actualNodes[nodeIndex], - current.X, - targetY, - direction); - previousBottom = targetY + current.Height; - } - - continue; - } - - var previousRight = double.NegativeInfinity; - for (var nodeIndex = 0; nodeIndex < actualNodes.Length; nodeIndex++) - { - var current = positionedNodes[actualNodes[nodeIndex].Id]; - var targetX = current.X; - if (nodeIndex > 0) - { - targetX = Math.Max(targetX, previousRight + nodeSpacing); - } - - if (ShouldCompactTowardIncoming(actualNodes[nodeIndex].Id, incomingNodeIds, positionedNodes)) - { - var preferredCenter = ResolveIncomingPreferredCenter( - actualNodes[nodeIndex].Id, - incomingNodeIds, - positionedNodes, - horizontal: false); - if (preferredCenter.HasValue) - { - targetX = Math.Max( - nodeIndex > 0 ? previousRight + nodeSpacing : double.NegativeInfinity, - Math.Min(current.X, preferredCenter.Value - (current.Width / 2d))); - } - } - - positionedNodes[actualNodes[nodeIndex].Id] = CreatePositionedNode( - actualNodes[nodeIndex], - targetX, - current.Y, - direction); - previousRight = targetX + current.Width; - } - } - } - } - - private static bool ShouldCompactTowardIncoming( - string nodeId, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary positionedNodes) - { - if (!positionedNodes.TryGetValue(nodeId, out var currentNode) - || string.Equals(currentNode.Kind, "Start", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) - { - return false; - } - - return incomingIds - .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) - .Where(incomingNode => incomingNode is not null) - .All(incomingNode => !string.Equals(incomingNode!.Kind, "Fork", StringComparison.OrdinalIgnoreCase)); - } - - private static double? ResolveIncomingPreferredCenter( - string nodeId, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary positionedNodes, - bool horizontal) - { - if (!incomingNodeIds.TryGetValue(nodeId, out var incomingIds) || incomingIds.Count == 0) - { - return null; - } - - var centers = incomingIds - .Select(incomingId => positionedNodes.GetValueOrDefault(incomingId)) - .Where(incomingNode => incomingNode is not null) - .Select(incomingNode => horizontal - ? incomingNode!.Y + (incomingNode.Height / 2d) - : incomingNode!.X + (incomingNode.Width / 2d)) - .OrderBy(center => center) - .ToArray(); - if (centers.Length == 0) - { - return null; - } - - var mid = centers.Length / 2; - return centers.Length % 2 == 1 - ? centers[mid] - : (centers[mid - 1] + centers[mid]) / 2d; - } - - private static void AlignDummyNodesToFlow( - Dictionary positionedNodes, - IReadOnlyList layers, - IReadOnlySet dummyNodeIds, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary nodesById, - ElkLayoutDirection direction) - { - for (var iteration = 0; iteration < 2; iteration++) - { - foreach (var layer in layers) - { - for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) - { - var node = layer[nodeIndex]; - if (!dummyNodeIds.Contains(node.Id)) - { - continue; - } - - var current = positionedNodes[node.Id]; - var preferredCenter = ResolvePreferredCenter( - node.Id, - incomingNodeIds, - outgoingNodeIds, - positionedNodes, - horizontal: direction == ElkLayoutDirection.LeftToRight); - if (!preferredCenter.HasValue) - { - continue; - } - - if (direction == ElkLayoutDirection.LeftToRight) - { - var minY = nodeIndex == 0 - ? double.NegativeInfinity - : positionedNodes[layer[nodeIndex - 1].Id].Y + positionedNodes[layer[nodeIndex - 1].Id].Height + 1d; - var maxY = nodeIndex == layer.Length - 1 - ? double.PositiveInfinity - : positionedNodes[layer[nodeIndex + 1].Id].Y - current.Height - 1d; - var desiredY = Clamp(preferredCenter.Value - (current.Height / 2d), minY, maxY); - positionedNodes[node.Id] = CreatePositionedNode(nodesById[node.Id], current.X, desiredY, direction); - continue; - } - - var minX = nodeIndex == 0 - ? double.NegativeInfinity - : positionedNodes[layer[nodeIndex - 1].Id].X + positionedNodes[layer[nodeIndex - 1].Id].Width + 1d; - var maxX = nodeIndex == layer.Length - 1 - ? double.PositiveInfinity - : positionedNodes[layer[nodeIndex + 1].Id].X - current.Width - 1d; - var desiredX = Clamp(preferredCenter.Value - (current.Width / 2d), minX, maxX); - positionedNodes[node.Id] = CreatePositionedNode(nodesById[node.Id], desiredX, current.Y, direction); - } - } - } - } - - private static double? ResolveOriginalPreferredCenter( - string nodeId, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary positionedNodes, - bool horizontal) - { - if (!positionedNodes.TryGetValue(nodeId, out var currentNode)) - { - return null; - } - - incomingNodeIds.TryGetValue(nodeId, out var incomingIds); - if (incomingIds is not null - && string.Equals(currentNode.Kind, "Join", StringComparison.OrdinalIgnoreCase) - && incomingIds.Count > 0) - { - var branchCenters = incomingIds - .Select(adjacentNodeId => positionedNodes.GetValueOrDefault(adjacentNodeId)) - .Where(adjacentNode => adjacentNode is not null) - .Select(adjacentNode => horizontal - ? adjacentNode!.Y + (adjacentNode.Height / 2d) - : adjacentNode!.X + (adjacentNode.Width / 2d)) - .OrderBy(center => center) - .ToArray(); - if (branchCenters.Length > 0) - { - var mid = branchCenters.Length / 2; - return branchCenters.Length % 2 == 1 - ? branchCenters[mid] - : (branchCenters[mid - 1] + branchCenters[mid]) / 2d; - } - } - - if (incomingIds is not null - && incomingIds.Count == 1 - && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) - && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) - && predecessorOutgoing.Count == 1) - { - return horizontal - ? linearPredecessor.Y + (linearPredecessor.Height / 2d) - : linearPredecessor.X + (linearPredecessor.Width / 2d); - } - - if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) - && outgoingIds.Count == 1 - && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) - && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) - && successorIncoming.Count == 1) - { - return horizontal - ? linearSuccessor.Y + (linearSuccessor.Height / 2d) - : linearSuccessor.X + (linearSuccessor.Width / 2d); - } - - var allCenters = new List(); - if (incomingIds is not null) - { - foreach (var adjacentNodeId in incomingIds) - { - if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) - { - allCenters.Add(horizontal - ? adjacent.Y + (adjacent.Height / 2d) - : adjacent.X + (adjacent.Width / 2d)); - } - } - } - - if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) - { - foreach (var adjacentNodeId in outgoingIds) - { - if (positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) - { - allCenters.Add(horizontal - ? adjacent.Y + (adjacent.Height / 2d) - : adjacent.X + (adjacent.Width / 2d)); - } - } - } - - if (allCenters.Count > 0) - { - allCenters.Sort(); - var mid = allCenters.Count / 2; - return allCenters.Count % 2 == 1 - ? allCenters[mid] - : (allCenters[mid - 1] + allCenters[mid]) / 2d; - } - - return null; - } - - private static double? ResolvePreferredCenter( - string nodeId, - IReadOnlyDictionary> incomingNodeIds, - IReadOnlyDictionary> outgoingNodeIds, - IReadOnlyDictionary positionedNodes, - bool horizontal) - { - if (incomingNodeIds.TryGetValue(nodeId, out var incomingIds) - && incomingIds.Count == 1 - && positionedNodes.TryGetValue(incomingIds[0], out var linearPredecessor) - && outgoingNodeIds.TryGetValue(incomingIds[0], out var predecessorOutgoing) - && predecessorOutgoing.Count == 1) - { - return horizontal - ? linearPredecessor.Y + (linearPredecessor.Height / 2d) - : linearPredecessor.X + (linearPredecessor.Width / 2d); - } - - if (outgoingNodeIds.TryGetValue(nodeId, out var outgoingIds) - && outgoingIds.Count == 1 - && positionedNodes.TryGetValue(outgoingIds[0], out var linearSuccessor) - && incomingNodeIds.TryGetValue(outgoingIds[0], out var successorIncoming) - && successorIncoming.Count == 1) - { - return horizontal - ? linearSuccessor.Y + (linearSuccessor.Height / 2d) - : linearSuccessor.X + (linearSuccessor.Width / 2d); - } - - var coordinates = new List(); - if (incomingNodeIds.TryGetValue(nodeId, out incomingIds)) - { - foreach (var adjacentNodeId in incomingIds) - { - if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) - { - continue; - } - - coordinates.Add(horizontal - ? adjacent.Y + (adjacent.Height / 2d) - : adjacent.X + (adjacent.Width / 2d)); - } - } - - if (outgoingNodeIds.TryGetValue(nodeId, out outgoingIds)) - { - foreach (var adjacentNodeId in outgoingIds) - { - if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) - { - continue; - } - - coordinates.Add(horizontal - ? adjacent.Y + (adjacent.Height / 2d) - : adjacent.X + (adjacent.Width / 2d)); - } - } - - if (coordinates.Count == 0) - { - return null; - } - - coordinates.Sort(); - var mid = coordinates.Count / 2; - return coordinates.Count % 2 == 1 - ? coordinates[mid] - : (coordinates[mid - 1] + coordinates[mid]) / 2d; - } - - private static void EnforceLinearSpacing( - IReadOnlyList layer, - double[] desiredCoordinates, - double spacing, - bool horizontal) - { - for (var index = 1; index < layer.Count; index++) - { - var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; - desiredCoordinates[index] = Math.Max( - desiredCoordinates[index], - desiredCoordinates[index - 1] + extent + spacing); - } - - for (var index = layer.Count - 2; index >= 0; index--) - { - var extent = horizontal ? layer[index].Height : layer[index].Width; - desiredCoordinates[index] = Math.Min( - desiredCoordinates[index], - desiredCoordinates[index + 1] - extent - spacing); - } - - for (var index = 1; index < layer.Count; index++) - { - var extent = horizontal ? layer[index - 1].Height : layer[index - 1].Width; - desiredCoordinates[index] = Math.Max( - desiredCoordinates[index], - desiredCoordinates[index - 1] + extent + spacing); - } - } - - private static void OrderLayer( - IReadOnlyList> layers, - int layerIndex, - IReadOnlyDictionary> adjacentNodeIds, - IReadOnlyDictionary inputOrder) - { - var positions = BuildNodeOrderPositions(layers); - var currentLayer = layers[layerIndex]; - currentLayer.Sort((left, right) => - { - var leftRank = ResolveOrderingRank(left.Id, adjacentNodeIds, positions); - var rightRank = ResolveOrderingRank(right.Id, adjacentNodeIds, positions); - var comparison = leftRank.CompareTo(rightRank); - if (comparison != 0) - { - return comparison; - } - - comparison = positions[left.Id].CompareTo(positions[right.Id]); - if (comparison != 0) - { - return comparison; - } - - return inputOrder[left.Id].CompareTo(inputOrder[right.Id]); - }); - } - - private static Dictionary BuildNodeOrderPositions(IReadOnlyList> layers) - { - var positions = new Dictionary(StringComparer.Ordinal); - foreach (var layer in layers) - { - for (var index = 0; index < layer.Count; index++) - { - positions[layer[index].Id] = index; - } - } - - return positions; - } - - private static double ResolveOrderingRank( - string nodeId, - IReadOnlyDictionary> adjacentNodeIds, - IReadOnlyDictionary positions) - { - if (!adjacentNodeIds.TryGetValue(nodeId, out var neighbors) || neighbors.Count == 0) - { - return double.PositiveInfinity; - } - - var ordered = neighbors - .Where(positions.ContainsKey) - .Select(neighborId => (double)positions[neighborId]) - .OrderBy(value => value) - .ToArray(); - - if (ordered.Length == 0) - { - return double.PositiveInfinity; - } - - var middle = ordered.Length / 2; - return ordered.Length % 2 == 1 - ? ordered[middle] - : (ordered[middle - 1] + ordered[middle]) / 2d; - } - - private static IReadOnlyCollection NormalizeBendPoints(params ElkPoint[] points) - { - if (points.Length == 0) - { - return []; - } - - var normalized = new List(points.Length); - foreach (var point in points) - { - if (normalized.Count > 0 - && Math.Abs(normalized[^1].X - point.X) <= 0.01d - && Math.Abs(normalized[^1].Y - point.Y) <= 0.01d) - { - continue; - } - - normalized.Add(point); - } - - return normalized; - } - - private static (Dictionary InputOrder, HashSet BackEdgeIds) BuildTraversalInputOrder( - IReadOnlyCollection nodes, - IReadOnlyCollection edges, - IReadOnlyDictionary nodesById) - { - var originalOrder = nodes - .Select((node, index) => new KeyValuePair(node.Id, index)) - .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); - var outgoing = nodes.ToDictionary(node => node.Id, _ => new List(), StringComparer.Ordinal); - var incomingCount = nodes.ToDictionary(node => node.Id, _ => 0, StringComparer.Ordinal); - - foreach (var edge in edges) - { - outgoing[edge.SourceNodeId].Add(edge); - incomingCount[edge.TargetNodeId] = incomingCount[edge.TargetNodeId] + 1; - } - - var orderedNodeIds = new List(nodes.Count); - var visited = new HashSet(StringComparer.Ordinal); - var onStack = new HashSet(StringComparer.Ordinal); - var backEdgeIds = new HashSet(StringComparer.Ordinal); - var preferredRoots = nodes - .Where(node => string.Equals(node.Kind, "Start", StringComparison.Ordinal)) - .Concat(nodes.Where(node => !string.Equals(node.Kind, "Start", StringComparison.Ordinal) && incomingCount[node.Id] == 0)) - .OrderBy(node => originalOrder[node.Id], Comparer.Default) - .ToArray(); - - foreach (var root in preferredRoots) - { - Visit(root.Id); - } - - foreach (var node in nodes - .Where(node => !string.Equals(node.Kind, "End", StringComparison.Ordinal)) - .OrderBy(node => originalOrder[node.Id], Comparer.Default)) - { - Visit(node.Id); - } - - foreach (var endNode in nodes - .Where(node => string.Equals(node.Kind, "End", StringComparison.Ordinal)) - .OrderBy(node => originalOrder[node.Id], Comparer.Default)) - { - Visit(endNode.Id); - } - - var inputOrder = orderedNodeIds - .Select((nodeId, index) => new KeyValuePair(nodeId, index)) - .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); - - return (inputOrder, backEdgeIds); - - void Visit(string nodeId) - { - if (!visited.Add(nodeId)) - { - return; - } - - onStack.Add(nodeId); - orderedNodeIds.Add(nodeId); - foreach (var edge in outgoing[nodeId] - .OrderBy(edge => string.Equals(nodesById[edge.TargetNodeId].Kind, "End", StringComparison.Ordinal) ? 1 : 0) - .ThenBy(edge => originalOrder[edge.TargetNodeId], Comparer.Default)) - { - if (onStack.Contains(edge.TargetNodeId)) - { - backEdgeIds.Add(edge.Id); - } - - Visit(edge.TargetNodeId); - } - - onStack.Remove(nodeId); - } - } - - private static ElkPoint IntersectDiamondBoundary( - double centerX, - double centerY, - double halfWidth, - double halfHeight, - double deltaX, - double deltaY) - { - if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) - { - return new ElkPoint - { - X = centerX, - Y = centerY, - }; - } - - var scale = 1d / ((Math.Abs(deltaX) / Math.Max(halfWidth, 0.001d)) + (Math.Abs(deltaY) / Math.Max(halfHeight, 0.001d))); - return new ElkPoint - { - X = centerX + (deltaX * scale), - Y = centerY + (deltaY * scale), - }; - } - - private static ElkPoint ResolveGatewayBoundaryPoint( - ElkPositionedNode node, - ElkPoint candidate, - double deltaX, - double deltaY) - { - if (node.Kind is not ("Decision" or "Fork" or "Join")) - { - return candidate; - } - - var centerX = node.X + (node.Width / 2d); - var centerY = node.Y + (node.Height / 2d); - if (Math.Abs(deltaX) < 0.001d && Math.Abs(deltaY) < 0.001d) - { - deltaX = candidate.X - centerX; - deltaY = candidate.Y - centerY; - } - - if (node.Kind == "Decision") - { - return IntersectDiamondBoundary(centerX, centerY, node.Width / 2d, node.Height / 2d, deltaX, deltaY); - } - - return IntersectPolygonBoundary( - centerX, - centerY, - deltaX, - deltaY, - BuildForkBoundaryPoints(node)); - } - - private static IReadOnlyList BuildForkBoundaryPoints(ElkPositionedNode node) - { - var cornerInset = Math.Min(22d, Math.Max(6d, node.Width * 0.125d)); - var verticalInset = Math.Min(8d, Math.Max(4d, node.Height * 0.065d)); - return - [ - new ElkPoint { X = node.X + cornerInset, Y = node.Y + verticalInset }, - new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + verticalInset }, - new ElkPoint { X = node.X + node.Width, Y = node.Y + (node.Height / 2d) }, - new ElkPoint { X = node.X + node.Width - cornerInset, Y = node.Y + node.Height - verticalInset }, - new ElkPoint { X = node.X + cornerInset, Y = node.Y + node.Height - verticalInset }, - new ElkPoint { X = node.X, Y = node.Y + (node.Height / 2d) }, - ]; - } - - private static ElkPoint IntersectPolygonBoundary( - double originX, - double originY, - double deltaX, - double deltaY, - IReadOnlyList polygon) - { - var bestScale = double.PositiveInfinity; - ElkPoint? bestPoint = null; - for (var index = 0; index < polygon.Count; index++) - { - var start = polygon[index]; - var end = polygon[(index + 1) % polygon.Count]; - if (!TryIntersectRayWithSegment(originX, originY, deltaX, deltaY, start, end, out var scale, out var point)) - { - continue; - } - - if (scale < bestScale) - { - bestScale = scale; - bestPoint = point; - } - } - - return bestPoint ?? new ElkPoint - { - X = originX + deltaX, - Y = originY + deltaY, - }; - } - - private static bool TryIntersectRayWithSegment( - double originX, - double originY, - double deltaX, - double deltaY, - ElkPoint segmentStart, - ElkPoint segmentEnd, - out double scale, - out ElkPoint point) - { - scale = double.PositiveInfinity; - point = default!; - - var segmentDeltaX = segmentEnd.X - segmentStart.X; - var segmentDeltaY = segmentEnd.Y - segmentStart.Y; - var denominator = Cross(deltaX, deltaY, segmentDeltaX, segmentDeltaY); - if (Math.Abs(denominator) <= 0.001d) - { - return false; - } - - var relativeX = segmentStart.X - originX; - var relativeY = segmentStart.Y - originY; - var rayScale = Cross(relativeX, relativeY, segmentDeltaX, segmentDeltaY) / denominator; - var segmentScale = Cross(relativeX, relativeY, deltaX, deltaY) / denominator; - if (rayScale < 0d || segmentScale < 0d || segmentScale > 1d) - { - return false; - } - - scale = rayScale; - point = new ElkPoint - { - X = originX + (deltaX * rayScale), - Y = originY + (deltaY * rayScale), - }; - return true; - } - - private static double Cross(double ax, double ay, double bx, double by) - { - return (ax * by) - (ay * bx); - } - - private static DummyNodeResult InsertDummyNodes( - IReadOnlyCollection originalNodes, - IReadOnlyCollection originalEdges, - Dictionary layersByNodeId, - IReadOnlyDictionary inputOrder, - IReadOnlySet backEdgeIds) - { - var allNodes = new List(originalNodes); - var allEdges = new List(); - var augmentedLayers = new Dictionary(layersByNodeId, StringComparer.Ordinal); - var augmentedInputOrder = new Dictionary(inputOrder, StringComparer.Ordinal); - var dummyNodeIds = new HashSet(StringComparer.Ordinal); - var edgeDummyChains = new Dictionary>(StringComparer.Ordinal); - var nextInputOrder = inputOrder.Values.Max() + 1; - - foreach (var edge in originalEdges) - { - if (backEdgeIds.Contains(edge.Id)) - { - allEdges.Add(edge); - continue; - } - - var sourceLayer = layersByNodeId.GetValueOrDefault(edge.SourceNodeId, 0); - var targetLayer = layersByNodeId.GetValueOrDefault(edge.TargetNodeId, 0); - var span = targetLayer - sourceLayer; - - if (span <= 1) - { - allEdges.Add(edge); - continue; - } - - var chain = new List(); - var previousNodeId = edge.SourceNodeId; - - for (var layer = sourceLayer + 1; layer < targetLayer; layer++) - { - var dummyId = $"__dummy_{edge.Id}_{layer}"; - var dummyNode = new ElkNode - { - Id = dummyId, - Label = string.Empty, - Kind = "Dummy", - Width = 1, - Height = 1, - }; - - allNodes.Add(dummyNode); - augmentedLayers[dummyId] = layer; - augmentedInputOrder[dummyId] = inputOrder.GetValueOrDefault(edge.SourceNodeId, nextInputOrder); - dummyNodeIds.Add(dummyId); - chain.Add(dummyId); - - allEdges.Add(new ElkEdge - { - Id = $"{edge.Id}__seg_{layer}", - SourceNodeId = previousNodeId, - TargetNodeId = dummyId, - }); - - previousNodeId = dummyId; - } - - allEdges.Add(new ElkEdge - { - Id = $"{edge.Id}__seg_{targetLayer}", - SourceNodeId = previousNodeId, - TargetNodeId = edge.TargetNodeId, - }); - - edgeDummyChains[edge.Id] = chain; - } - - return new DummyNodeResult(allNodes, allEdges, augmentedLayers, augmentedInputOrder, dummyNodeIds, edgeDummyChains); - } - - private static Dictionary ReconstructDummyEdges( - IReadOnlyCollection originalEdges, - DummyNodeResult dummyResult, - IReadOnlyDictionary positionedNodes, - IReadOnlyDictionary augmentedNodesById, - ElkLayoutDirection direction, - GraphBounds graphBounds, - IReadOnlyDictionary edgeChannels, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var edgesWithChains = originalEdges - .Where(e => dummyResult.EdgeDummyChains.ContainsKey(e.Id)) - .ToArray(); - - var incomingByTarget = edgesWithChains - .GroupBy(e => e.TargetNodeId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.OrderBy(e => - { - var s = positionedNodes[e.SourceNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? s.Y + (s.Height / 2d) - : s.X + (s.Width / 2d); - }).ToArray(), StringComparer.Ordinal); - - var outgoingBySource = edgesWithChains - .GroupBy(e => e.SourceNodeId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.OrderBy(e => - { - var t = positionedNodes[e.TargetNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? t.Y + (t.Height / 2d) - : t.X + (t.Width / 2d); - }).ToArray(), StringComparer.Ordinal); - - var reconstructed = new Dictionary(StringComparer.Ordinal); - - foreach (var edge in edgesWithChains) - { - if (!dummyResult.EdgeDummyChains.TryGetValue(edge.Id, out var chain) || chain.Count == 0) - { - continue; - } - - var channel = edgeChannels.GetValueOrDefault(edge.Id); - if (channel.RouteMode != EdgeRouteMode.Direct - || !string.IsNullOrWhiteSpace(edge.SourcePortId) - || !string.IsNullOrWhiteSpace(edge.TargetPortId)) - { - reconstructed[edge.Id] = RouteEdge( - edge, - augmentedNodesById, - positionedNodes, - direction, - graphBounds, - channel, - layerBoundariesByNodeId); - continue; - } - - var sourceNode = positionedNodes[edge.SourceNodeId]; - var targetNode = positionedNodes[edge.TargetNodeId]; - var sourceGroup = outgoingBySource.GetValueOrDefault(edge.SourceNodeId); - var targetGroup = incomingByTarget.GetValueOrDefault(edge.TargetNodeId); - - if (ShouldRouteLongEdgeViaDirectRouter(edge, sourceNode, targetNode, sourceGroup, targetGroup, positionedNodes, direction)) - { - reconstructed[edge.Id] = RouteEdge( - edge, - augmentedNodesById, - positionedNodes, - direction, - graphBounds, - channel, - layerBoundariesByNodeId); - continue; - } - - var bendPoints = new List(); - foreach (var dummyId in chain) - { - if (positionedNodes.TryGetValue(dummyId, out var dummyPos)) - { - bendPoints.Add(new ElkPoint - { - X = dummyPos.X + (dummyPos.Width / 2d), - Y = dummyPos.Y + (dummyPos.Height / 2d), - }); - } - } - - var sourceExitY = ResolveGroupedAnchorCoordinate(sourceNode, edge, sourceGroup, positionedNodes, isSource: true, direction); - var targetEntryY = ResolveGroupedAnchorCoordinate(targetNode, edge, targetGroup, positionedNodes, isSource: false, direction); - - var targetCenter = new ElkPoint - { - X = targetNode.X + (targetNode.Width / 2d), - Y = targetNode.Y + (targetNode.Height / 2d), - }; - var sourceAnchor = ComputeSmartAnchor(sourceNode, targetCenter, - true, sourceExitY, sourceGroup?.Length ?? 1, direction); - var targetAnchor = ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null, - false, targetEntryY, targetGroup?.Length ?? 1, direction); - - reconstructed[edge.Id] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - SourcePortId = edge.SourcePortId, - TargetPortId = edge.TargetPortId, - Kind = edge.Kind, - Label = edge.Label, - Sections = - [ - new ElkEdgeSection - { - StartPoint = sourceAnchor, - EndPoint = targetAnchor, - BendPoints = bendPoints, - }, - ], - }; - } - - return reconstructed; - } - - private static bool ShouldRouteLongEdgeViaDirectRouter( - ElkEdge edge, - ElkPositionedNode sourceNode, - ElkPositionedNode targetNode, - IReadOnlyList? sourceGroup, - IReadOnlyList? targetGroup, - IReadOnlyDictionary positionedNodes, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return false; - } - - var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); - var targetCenterY = targetNode.Y + (targetNode.Height / 2d); - var rowTolerance = Math.Max(28d, Math.Min(sourceNode.Height, targetNode.Height) * 0.4d); - if (Math.Abs(sourceCenterY - targetCenterY) <= rowTolerance) - { - return true; - } - - var sourcePrimaryIndex = ResolvePrimaryAxisGroupIndex(sourceNode, sourceGroup, positionedNodes, isSource: true, direction); - var targetPrimaryIndex = ResolvePrimaryAxisGroupIndex(targetNode, targetGroup, positionedNodes, isSource: false, direction); - if (sourcePrimaryIndex < 0 && targetPrimaryIndex < 0) - { - return false; - } - - var sourceIndex = sourceGroup is null - ? -1 - : Array.FindIndex(sourceGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); - var targetIndex = targetGroup is null - ? -1 - : Array.FindIndex(targetGroup.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); - - return sourceIndex >= 0 && sourceIndex == sourcePrimaryIndex - || targetIndex >= 0 && targetIndex == targetPrimaryIndex; - } - - private static double ResolveGroupedAnchorCoordinate( - ElkPositionedNode node, - ElkEdge edge, - IReadOnlyList? group, - IReadOnlyDictionary positionedNodes, - bool isSource, - ElkLayoutDirection direction) - { - var center = direction == ElkLayoutDirection.LeftToRight - ? node.Y + (node.Height / 2d) - : node.X + (node.Width / 2d); - if (group is null || group.Count <= 1) - { - return center; - } - - var index = Array.FindIndex(group.ToArray(), candidate => string.Equals(candidate.Id, edge.Id, StringComparison.Ordinal)); - if (index < 0) - { - return center; - } - - var primaryIndex = ResolvePrimaryAxisGroupIndex(node, group, positionedNodes, isSource, direction); - if (primaryIndex >= 0) - { - var spread = direction == ElkLayoutDirection.LeftToRight - ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) - : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); - var coordinate = center + ((index - primaryIndex) * spread); - return direction == ElkLayoutDirection.LeftToRight - ? Clamp(coordinate, node.Y + 6d, node.Y + node.Height - 6d) - : Clamp(coordinate, node.X + 6d, node.X + node.Width - 6d); - } - - var fallbackSpread = direction == ElkLayoutDirection.LeftToRight - ? Math.Min(14d, (node.Height - 12d) / Math.Max(1, group.Count)) - : Math.Min(14d, (node.Width - 12d) / Math.Max(1, group.Count)); - var total = (group.Count - 1) * fallbackSpread; - var fallback = center - (total / 2d) + (index * fallbackSpread); - return direction == ElkLayoutDirection.LeftToRight - ? Clamp(fallback, node.Y + 6d, node.Y + node.Height - 6d) - : Clamp(fallback, node.X + 6d, node.X + node.Width - 6d); - } - - private static int ResolvePrimaryAxisGroupIndex( - ElkPositionedNode node, - IReadOnlyList? group, - IReadOnlyDictionary positionedNodes, - bool isSource, - ElkLayoutDirection direction) - { - if (group is null || group.Count == 0) - { - return -1; - } - - var reserveAxis = isSource - ? string.Equals(node.Kind, "Fork", StringComparison.OrdinalIgnoreCase) - || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase) - : string.Equals(node.Kind, "Join", StringComparison.OrdinalIgnoreCase) - || string.Equals(node.Kind, "Decision", StringComparison.OrdinalIgnoreCase); - if (!reserveAxis) - { - return -1; - } - - var center = direction == ElkLayoutDirection.LeftToRight - ? node.Y + (node.Height / 2d) - : node.X + (node.Width / 2d); - var bestIndex = 0; - var bestDistance = double.PositiveInfinity; - for (var index = 0; index < group.Count; index++) - { - var adjacentNodeId = isSource ? group[index].TargetNodeId : group[index].SourceNodeId; - if (!positionedNodes.TryGetValue(adjacentNodeId, out var adjacent)) - { - continue; - } - - var adjacentCenter = direction == ElkLayoutDirection.LeftToRight - ? adjacent.Y + (adjacent.Height / 2d) - : adjacent.X + (adjacent.Width / 2d); - var distance = Math.Abs(adjacentCenter - center); - if (distance < bestDistance) - { - bestDistance = distance; - bestIndex = index; - } - } - - return bestIndex; - } - - private static ElkPoint ComputeSmartAnchor( - ElkPositionedNode node, - ElkPoint? approachPoint, - bool isSource, - double spreadY, - int groupSize, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight || approachPoint is null) - { - var fallback = isSource - ? new ElkPoint { X = node.X + node.Width, Y = Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) } - : new ElkPoint { X = node.X, Y = Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) }; - return ResolveGatewayBoundaryPoint( - node, - fallback, - fallback.X - (node.X + (node.Width / 2d)), - fallback.Y - (node.Y + (node.Height / 2d))); - } - - var nodeCenterX = node.X + (node.Width / 2d); - var nodeCenterY = node.Y + (node.Height / 2d); - var deltaX = approachPoint.X - nodeCenterX; - var deltaY = approachPoint.Y - nodeCenterY; - - if (isSource) - { - if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY < 0d) - { - var topCandidate = new ElkPoint - { - X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), - Y = node.Y, - }; - return ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); - } - - if (Math.Abs(deltaY) > Math.Abs(deltaX) * 1.5d && deltaY > 0d) - { - var bottomCandidate = new ElkPoint - { - X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), - Y = node.Y + node.Height, - }; - return ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); - } - - var eastCandidate = new ElkPoint - { - X = node.X + node.Width, - Y = groupSize > 1 ? Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) - : Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), - }; - return ResolveGatewayBoundaryPoint(node, eastCandidate, eastCandidate.X - nodeCenterX, eastCandidate.Y - nodeCenterY); - } - - if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY < 0d) - { - var topCandidate = new ElkPoint - { - X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), - Y = node.Y, - }; - return ResolveGatewayBoundaryPoint(node, topCandidate, topCandidate.X - nodeCenterX, topCandidate.Y - nodeCenterY); - } - - if (Math.Abs(deltaY) > Math.Abs(deltaX) * 0.8d && deltaY > 0d) - { - var bottomCandidate = new ElkPoint - { - X = Clamp(approachPoint.X, node.X + 8d, node.X + node.Width - 8d), - Y = node.Y + node.Height, - }; - return ResolveGatewayBoundaryPoint(node, bottomCandidate, bottomCandidate.X - nodeCenterX, bottomCandidate.Y - nodeCenterY); - } - - var westCandidate = new ElkPoint - { - X = node.X, - Y = groupSize > 1 ? Clamp(spreadY, node.Y + 6d, node.Y + node.Height - 6d) - : Clamp(approachPoint.Y, node.Y + 6d, node.Y + node.Height - 6d), - }; - return ResolveGatewayBoundaryPoint(node, westCandidate, westCandidate.X - nodeCenterX, westCandidate.Y - nodeCenterY); - } - - private sealed record DummyNodeResult( - List AllNodes, - List AllEdges, - Dictionary AugmentedLayers, - Dictionary AugmentedInputOrder, - HashSet DummyNodeIds, - Dictionary> EdgeDummyChains); - - private static GraphBounds ComputeGraphBounds(ICollection nodes) - { - return new GraphBounds( - nodes.Min(n => n.X), - nodes.Min(n => n.Y), - nodes.Max(n => n.X + n.Width), - nodes.Max(n => n.Y + n.Height)); - } - - private static bool ExpandVerticalCorridorGutters( - Dictionary positionedNodes, - IReadOnlyCollection routedEdges, - IReadOnlyDictionary layersByNodeId, - IReadOnlyDictionary nodesById, - double baseLayerSpacing, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return false; - } - - var boundariesByLayer = layersByNodeId - .Where(entry => positionedNodes.ContainsKey(entry.Key)) - .GroupBy(entry => entry.Value) - .OrderBy(group => group.Key) - .Select(group => - { - var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); - return new - { - Layer = group.Key, - Boundary = new LayerBoundary( - nodes.Min(node => node.X), - nodes.Max(node => node.X + node.Width), - nodes.Min(node => node.Y), - nodes.Max(node => node.Y + node.Height)), - }; - }) - .ToArray(); - if (boundariesByLayer.Length < 2) - { - return false; - } - - var requiredBoundaryDeltas = new Dictionary(); - for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) - { - var current = boundariesByLayer[boundaryIndex]; - var next = boundariesByLayer[boundaryIndex + 1]; - var gap = next.Boundary.MinX - current.Boundary.MaxX; - if (gap <= 0d) - { - continue; - } - - var verticalSegments = routedEdges - .SelectMany(edge => edge.Sections.SelectMany(section => - { - var points = new List { section.StartPoint }; - points.AddRange(section.BendPoints); - points.Add(section.EndPoint); - return points.Zip(points.Skip(1), (start, end) => new - { - Edge = edge, - Start = start, - End = end, - }); - })) - .Where(segment => - Math.Abs(segment.Start.X - segment.End.X) <= 0.01d - && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d - && segment.Start.X > current.Boundary.MaxX + 8d - && segment.Start.X < next.Boundary.MinX - 8d) - .ToArray(); - var laneCount = verticalSegments - .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) - .Distinct() - .Count(); - if (laneCount == 0) - { - continue; - } - - var familyCount = verticalSegments - .Select(segment => ResolveLaneFamilyKey(segment.Edge.Label)) - .Distinct(StringComparer.Ordinal) - .Count(); - var desiredGap = Math.Max( - baseLayerSpacing + 88d, - 136d + (laneCount * 28d) + (Math.Max(0, familyCount - 1) * 24d)); - if (gap >= desiredGap) - { - continue; - } - - requiredBoundaryDeltas[current.Layer] = desiredGap - gap; - } - - if (requiredBoundaryDeltas.Count == 0) - { - return false; - } - - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) - { - continue; - } - - var shiftX = requiredBoundaryDeltas - .Where(entry => nodeLayer > entry.Key) - .Sum(entry => entry.Value); - if (shiftX <= 0.01d) - { - continue; - } - - var current = positionedNodes[nodeId]; - positionedNodes[nodeId] = CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); - } - - return true; - } - - private static bool CompactSparseVerticalCorridorGutters( - Dictionary positionedNodes, - IReadOnlyCollection routedEdges, - IReadOnlyDictionary layersByNodeId, - IReadOnlyDictionary nodesById, - double baseLayerSpacing, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return false; - } - - var boundariesByLayer = layersByNodeId - .Where(entry => positionedNodes.ContainsKey(entry.Key)) - .GroupBy(entry => entry.Value) - .OrderBy(group => group.Key) - .Select(group => - { - var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); - return new - { - Layer = group.Key, - Boundary = new LayerBoundary( - nodes.Min(node => node.X), - nodes.Max(node => node.X + node.Width), - nodes.Min(node => node.Y), - nodes.Max(node => node.Y + node.Height)), - }; - }) - .ToArray(); - if (boundariesByLayer.Length < 2) - { - return false; - } - - var boundaryShifts = new Dictionary(); - for (var boundaryIndex = 0; boundaryIndex < boundariesByLayer.Length - 1; boundaryIndex++) - { - var current = boundariesByLayer[boundaryIndex]; - var next = boundariesByLayer[boundaryIndex + 1]; - var gap = next.Boundary.MinX - current.Boundary.MaxX; - if (gap <= 0d) - { - continue; - } - - var verticalSegments = routedEdges - .SelectMany(edge => edge.Sections.SelectMany(section => - { - var points = new List { section.StartPoint }; - points.AddRange(section.BendPoints); - points.Add(section.EndPoint); - return points.Zip(points.Skip(1), (start, end) => new - { - Edge = edge, - Start = start, - End = end, - }); - })) - .Where(segment => - Math.Abs(segment.Start.X - segment.End.X) <= 0.01d - && Math.Abs(segment.End.Y - segment.Start.Y) >= 36d - && segment.Start.X > current.Boundary.MaxX + 8d - && segment.Start.X < next.Boundary.MinX - 8d) - .ToArray(); - var laneCount = verticalSegments - .Select(segment => Math.Round(segment.Start.X / 12d) * 12d) - .Distinct() - .Count(); - var familyCount = verticalSegments - .Select(segment => ResolveLaneFamilyKey(segment.Edge.Label)) - .Distinct(StringComparer.Ordinal) - .Count(); - - var desiredGap = Math.Max( - baseLayerSpacing * 0.72d, - 120d + (laneCount * 20d) + (Math.Max(0, familyCount - 1) * 16d)); - var maxGap = desiredGap + 28d; - if (gap <= maxGap) - { - continue; - } - - boundaryShifts[current.Layer] = desiredGap - gap; - } - - if (boundaryShifts.Count == 0) - { - return false; - } - - foreach (var nodeId in positionedNodes.Keys.ToArray()) - { - if (!layersByNodeId.TryGetValue(nodeId, out var nodeLayer)) - { - continue; - } - - var shiftX = boundaryShifts - .Where(entry => nodeLayer > entry.Key) - .Sum(entry => entry.Value); - if (Math.Abs(shiftX) <= 0.01d) - { - continue; - } - - var current = positionedNodes[nodeId]; - positionedNodes[nodeId] = CreatePositionedNode(nodesById[nodeId], current.X + shiftX, current.Y, direction); - } - - return true; - } - - private static double ResolveBackwardCorridorY( - IReadOnlyCollection edges, - IReadOnlyDictionary positionedNodes) - { - if (edges.Count == 0) - { - return double.NaN; - } - - var spanMinX = double.PositiveInfinity; - var spanMaxX = double.NegativeInfinity; - var endpointTop = double.PositiveInfinity; - foreach (var edge in edges) - { - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); - spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); - endpointTop = Math.Min(endpointTop, Math.Min(source.Y, target.Y)); - } - - var spanNodes = positionedNodes.Values - .Where(node => - !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) - && (node.X + node.Width) >= spanMinX - 1d - && node.X <= spanMaxX + 1d) - .ToArray(); - var occupiedIntervals = spanNodes - .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) - .OrderBy(interval => interval.Top) - .ToArray(); - if (occupiedIntervals.Length == 0) - { - return double.NaN; - } - - var merged = new List<(double Top, double Bottom)>(); - foreach (var interval in occupiedIntervals) - { - if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) - { - merged.Add(interval); - continue; - } - - merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); - } - - var maxAllowed = endpointTop - 24d; - if (maxAllowed <= merged[0].Top) - { - return double.NaN; - } - - double bestScore = double.NegativeInfinity; - double bestCandidate = double.NaN; - for (var index = 0; index < merged.Count - 1; index++) - { - var freeTop = merged[index].Bottom; - var freeBottom = Math.Min(maxAllowed, merged[index + 1].Top); - var gapHeight = freeBottom - freeTop; - if (gapHeight < 56d) - { - continue; - } - - var candidateMin = freeTop + 18d; - var candidateMax = freeBottom - 12d; - if (candidateMax <= candidateMin) - { - continue; - } - - var desiredY = Math.Min(maxAllowed - 8d, freeTop + (gapHeight * 0.72d)); - foreach (var fraction in s_corridorSampleFractions) - { - var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); - var score = ScoreHorizontalCorridorCandidate( - spanNodes, - freeTop, - freeBottom, - candidate, - desiredY, - [], - false, - freeTop); - if (score <= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - } - - return bestCandidate; - } - - private static double ResolveBackwardLowerCorridorY( - IReadOnlyCollection edges, - IReadOnlyDictionary positionedNodes) - { - if (edges.Count == 0) - { - return double.NaN; - } - - var spanMinX = double.PositiveInfinity; - var spanMaxX = double.NegativeInfinity; - var minAllowed = double.NegativeInfinity; - foreach (var edge in edges) - { - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); - spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); - minAllowed = Math.Max(minAllowed, Math.Max(source.Y + source.Height, target.Y + target.Height) + 14d); - } - - var spanNodes = positionedNodes.Values - .Where(node => - !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) - && (node.X + node.Width) >= spanMinX - 1d - && node.X <= spanMaxX + 1d) - .ToArray(); - var occupiedIntervals = spanNodes - .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) - .OrderBy(interval => interval.Top) - .ToArray(); - if (occupiedIntervals.Length == 0) - { - return double.NaN; - } - - var merged = new List<(double Top, double Bottom)>(); - foreach (var interval in occupiedIntervals) - { - if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) - { - merged.Add(interval); - continue; - } - - merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); - } - - double bestScore = double.NegativeInfinity; - double bestCandidate = double.NaN; - for (var index = 0; index < merged.Count - 1; index++) - { - var freeTop = Math.Max(minAllowed, merged[index].Bottom); - var freeBottom = merged[index + 1].Top; - var gapHeight = freeBottom - freeTop; - if (gapHeight < 44d) - { - continue; - } - - var candidateMin = freeTop + 12d; - var candidateMax = freeBottom - 12d; - if (candidateMax <= candidateMin) - { - continue; - } - - var desiredY = freeTop + Math.Min(40d, gapHeight * 0.32d); - foreach (var fraction in s_corridorSampleFractions) - { - var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); - var score = ScoreHorizontalCorridorCandidate( - spanNodes, - freeTop, - freeBottom, - candidate, - desiredY, - [], - true, - minAllowed) - + 28d; - if (score <= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - } - - return bestCandidate; - } - - private static double ResolveSinkCorridorY( - IReadOnlyCollection edges, - IReadOnlyDictionary positionedNodes, - IReadOnlyCollection reservedBands) - { - if (edges.Count == 0) - { - return double.NaN; - } - - var spanMinX = double.PositiveInfinity; - var spanMaxX = double.NegativeInfinity; - var minAllowed = double.NegativeInfinity; - foreach (var edge in edges) - { - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - spanMinX = Math.Min(spanMinX, Math.Min(source.X, target.X)); - spanMaxX = Math.Max(spanMaxX, Math.Max(source.X + source.Width, target.X + target.Width)); - minAllowed = Math.Max(minAllowed, source.Y + source.Height + 18d); - } - - var spanNodes = positionedNodes.Values - .Where(node => - !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase) - && (node.X + node.Width) >= spanMinX - 1d - && node.X <= spanMaxX + 1d) - .ToArray(); - var occupiedIntervals = spanNodes - .Select(node => (Top: node.Y, Bottom: node.Y + node.Height)) - .OrderBy(interval => interval.Top) - .ToArray(); - if (occupiedIntervals.Length == 0) - { - return double.NaN; - } - - var merged = new List<(double Top, double Bottom)>(); - foreach (var interval in occupiedIntervals) - { - if (merged.Count == 0 || interval.Top > merged[^1].Bottom + 0.01d) - { - merged.Add(interval); - continue; - } - - merged[^1] = (merged[^1].Top, Math.Max(merged[^1].Bottom, interval.Bottom)); - } - - var occupiedBottom = merged[^1].Bottom; - var desiredY = minAllowed + Math.Min(280d, Math.Max(0d, occupiedBottom - minAllowed) * 0.35d); - double bestScore = double.NegativeInfinity; - double bestCandidate = double.NaN; - - for (var index = 0; index < merged.Count - 1; index++) - { - var freeTop = Math.Max(minAllowed, merged[index].Bottom); - var freeBottom = merged[index + 1].Top; - var gapHeight = freeBottom - freeTop; - if (gapHeight < 56d) - { - continue; - } - - var candidateMin = freeTop + 20d; - var candidateMax = freeBottom - 20d; - if (candidateMax <= candidateMin) - { - continue; - } - - foreach (var fraction in s_corridorSampleFractions) - { - var candidate = candidateMin + ((candidateMax - candidateMin) * fraction); - var score = ScoreHorizontalCorridorCandidate( - spanNodes, - freeTop, - freeBottom, - candidate, - desiredY, - reservedBands, - true, - minAllowed); - if (score <= bestScore) - { - continue; - } - - bestScore = score; - bestCandidate = candidate; - } - } - - return bestCandidate; - } - - private static double ResolveSinkBandOffset(int bandIndex, double firstSpacing = 36d, double subsequentSpacing = 28d) - { - if (bandIndex <= 0) - { - return 0d; - } - - return firstSpacing + ((bandIndex - 1) * subsequentSpacing); - } - - private static readonly double[] s_corridorSampleFractions = [0.2d, 0.35d, 0.5d, 0.65d, 0.8d]; - - private static double ScoreHorizontalCorridorCandidate( - IReadOnlyCollection spanNodes, - double freeTop, - double freeBottom, - double candidate, - double desiredY, - IReadOnlyCollection reservedBands, - bool rewardInterior, - double minAllowed) - { - var gapHeight = freeBottom - freeTop; - var clearance = Math.Min(candidate - freeTop, freeBottom - candidate); - var bandSeparation = reservedBands.Count == 0 - ? 144d - : reservedBands.Min(band => Math.Abs(candidate - band)); - var rowPenalty = spanNodes.Sum(node => - { - var nodeTop = node.Y - 6d; - var nodeBottom = node.Y + node.Height + 6d; - if (candidate >= nodeTop && candidate <= nodeBottom) - { - return 100000d; - } - - var centerY = node.Y + (node.Height / 2d); - var distance = Math.Abs(centerY - candidate); - return distance >= 96d ? 0d : (96d - distance) * 0.32d; - }); - - return gapHeight - + (clearance * 1.8d) - + (Math.Min(120d, bandSeparation) * 0.7d) - - (Math.Abs(candidate - desiredY) * 0.35d) - - rowPenalty - + (rewardInterior && freeTop >= minAllowed + 64d ? 22d : 0d); - } - - private static bool CanUseLocalSinkDrop( - ElkEdge edge, - IReadOnlyDictionary positionedNodes, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return false; - } - - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - var sourceExitX = source.X + source.Width + 28d; - var sourceBottom = source.Y + source.Height; - var targetMidY = target.Y + (target.Height / 2d); - - return positionedNodes.Values - .Where(node => - !string.Equals(node.Id, source.Id, StringComparison.Ordinal) - && !string.Equals(node.Id, target.Id, StringComparison.Ordinal) - && !string.Equals(node.Kind, "Dummy", StringComparison.OrdinalIgnoreCase)) - .All(node => - sourceExitX < node.X - 12d - || sourceExitX > node.X + node.Width + 12d - || node.Y >= targetMidY - || node.Y + node.Height <= sourceBottom + 8d); - } - - private static bool ShouldPreferBottomSinkGutter( - ElkEdge edge, - IReadOnlyDictionary positionedNodes, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return false; - } - - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - if (!string.Equals(target.Kind, "End", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var horizontalSpan = target.X - (source.X + source.Width); - var verticalGap = target.Y - (source.Y + source.Height); - return horizontalSpan >= 960d && verticalGap >= 160d; - } - - private static Dictionary ComputeEdgeChannels( - IReadOnlyCollection edges, - IReadOnlyDictionary positionedNodes, - ElkLayoutDirection direction, - IReadOnlyDictionary layerBoundariesByNodeId) - { - var channels = new Dictionary(edges.Count, StringComparer.Ordinal); - var backwardEdges = new List(); - var forwardEdgesBySource = new Dictionary>(StringComparer.Ordinal); - var outgoingCounts = edges - .GroupBy(edge => edge.SourceNodeId, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); - - foreach (var edge in edges) - { - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - var isBackward = direction == ElkLayoutDirection.LeftToRight - ? (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d)) - : (target.Y + (target.Height / 2d)) < (source.Y + (source.Height / 2d)); - - if (isBackward) - { - backwardEdges.Add(edge); - } - else - { - if (!forwardEdgesBySource.TryGetValue(edge.SourceNodeId, out var list)) - { - list = []; - forwardEdgesBySource[edge.SourceNodeId] = list; - } - - list.Add(edge); - } - } - - var backwardGroups = backwardEdges - .GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal) - .SelectMany(targetGroup => - { - var families = targetGroup - .GroupBy(edge => ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) - .OrderBy(group => ResolveLaneFamilyPriority(group.First().Label)) - .ThenBy(group => group.Key, StringComparer.Ordinal) - .ToArray(); - return families.Select((familyGroup, familyIndex) => new - { - TargetNodeId = targetGroup.Key, - FamilyKey = familyGroup.Key, - FamilyIndex = familyIndex, - FamilyCount = families.Length, - Edges = familyGroup.ToArray(), - SharedOuterX = familyGroup.Max(edge => - { - var s = positionedNodes[edge.SourceNodeId]; - return s.X + s.Width; - }) + 56d, - Priority = ResolveLaneFamilyPriority(familyGroup.First().Label), - Span = familyGroup.Max(edge => - { - var s = positionedNodes[edge.SourceNodeId]; - var t = positionedNodes[edge.TargetNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? Math.Abs((s.X + (s.Width / 2d)) - (t.X + (t.Width / 2d))) - : Math.Abs((s.Y + (s.Height / 2d)) - (t.Y + (t.Height / 2d))); - }), - }); - }) - .OrderByDescending(group => group.Span) - .ThenBy(group => group.Priority) - .ThenBy(group => group.TargetNodeId, StringComparer.Ordinal) - .ToArray(); - - var reservedHorizontalBands = new List(); - for (var laneIndex = 0; laneIndex < backwardGroups.Length; laneIndex++) - { - var group = backwardGroups[laneIndex]; - var useSourceCollector = string.Equals(group.FamilyKey, "repeat", StringComparison.Ordinal); - var preferredOuterY = double.NaN; - if (direction == ElkLayoutDirection.LeftToRight && useSourceCollector) - { - var lowerCorridorY = ResolveBackwardLowerCorridorY(group.Edges, positionedNodes); - preferredOuterY = !double.IsNaN(lowerCorridorY) - ? lowerCorridorY - : ResolveBackwardCorridorY(group.Edges, positionedNodes); - } - - if (!double.IsNaN(preferredOuterY)) - { - reservedHorizontalBands.Add(preferredOuterY); - } - - foreach (var edge in group.Edges) - { - channels[edge.Id] = new EdgeChannel( - EdgeRouteMode.BackwardOuter, - laneIndex, - group.FamilyIndex, - group.FamilyCount, - 0, - 1, - 0, - 1, - -1, - 0, - group.SharedOuterX, - preferredOuterY, - useSourceCollector, - double.NaN); - } - } - - var forwardEdgesByTarget = new Dictionary>(StringComparer.Ordinal); - foreach (var sourceEdges in forwardEdgesBySource.Values) - { - foreach (var edge in sourceEdges) - { - if (!forwardEdgesByTarget.TryGetValue(edge.TargetNodeId, out var list)) - { - list = []; - forwardEdgesByTarget[edge.TargetNodeId] = list; - } - - list.Add(edge); - } - } - - var sinkBandsByEdgeId = new Dictionary(StringComparer.Ordinal); - if (direction == ElkLayoutDirection.LeftToRight) - { - var reservedSinkBands = new List(reservedHorizontalBands); - foreach (var targetEdges in forwardEdgesByTarget) - { - var targetNode = positionedNodes[targetEdges.Key]; - var isSinkTarget = string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) - || !outgoingCounts.ContainsKey(targetEdges.Key); - if (!isSinkTarget || targetEdges.Value.Count < 2) - { - continue; - } - - var sinkBands = targetEdges.Value - .GroupBy(edge => ResolveLaneFamilyKey(edge.Label), StringComparer.Ordinal) - .OrderBy(group => ResolveSinkLanePriority(group.First().Label)) - .ThenBy(group => group.Key, StringComparer.Ordinal) - .ToArray(); - for (var bandIndex = 0; bandIndex < sinkBands.Length; bandIndex++) - { - var sinkBandEdges = sinkBands[bandIndex].ToArray(); - var sharedOuterX = sinkBands[bandIndex].Max(edge => - { - var s = positionedNodes[edge.SourceNodeId]; - return s.X + s.Width; - }) + 56d; - var familyKey = ResolveLaneFamilyKey(sinkBands[bandIndex].First().Label); - var preferredOuterY = familyKey is "failure" or "timeout" - ? ResolveSinkCorridorY(sinkBandEdges, positionedNodes, reservedSinkBands) - : double.NaN; - if (!double.IsNaN(preferredOuterY)) - { - reservedSinkBands.Add(preferredOuterY + (bandIndex * 24d)); - } - - foreach (var edge in sinkBandEdges) - { - sinkBandsByEdgeId[edge.Id] = (bandIndex, sinkBands.Length, sharedOuterX, preferredOuterY); - } - } - } - } - - foreach (var sourceEdges in forwardEdgesBySource.Values) - { - var sorted = sourceEdges - .OrderBy(e => - { - var t = positionedNodes[e.TargetNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? t.Y + (t.Height / 2d) - : t.X + (t.Width / 2d); - }) - .ToArray(); - - for (var index = 0; index < sorted.Length; index++) - { - var targetEdges = forwardEdgesByTarget.GetValueOrDefault(sorted[index].TargetNodeId); - var targetIncomingIndex = 0; - var targetIncomingCount = 1; - if (targetEdges is not null && targetEdges.Count > 1) - { - var sortedBySourceY = targetEdges - .OrderBy(e => - { - var s = positionedNodes[e.SourceNodeId]; - return direction == ElkLayoutDirection.LeftToRight - ? s.Y + (s.Height / 2d) - : s.X + (s.Width / 2d); - }) - .ToList(); - targetIncomingIndex = sortedBySourceY.FindIndex(e => string.Equals(e.Id, sorted[index].Id, StringComparison.Ordinal)); - targetIncomingCount = sortedBySourceY.Count; - } - - var sinkBand = sinkBandsByEdgeId.GetValueOrDefault(sorted[index].Id, (-1, 0, 0d, double.NaN)); - var routeMode = EdgeRouteMode.Direct; - if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) - { - var familyKey = ResolveLaneFamilyKey(sorted[index].Label); - if (familyKey is "failure" or "timeout") - { - routeMode = EdgeRouteMode.SinkOuterTop; - } - else - { - routeMode = EdgeRouteMode.SinkOuter; - } - } - - channels[sorted[index].Id] = new EdgeChannel( - routeMode, - -1, - 0, - 1, - index, - sorted.Length, - targetIncomingIndex, - targetIncomingCount, - sinkBand.Item1, - sinkBand.Item2, - sinkBand.Item3, - sinkBand.Item4, - false, - double.NaN); - } - } - - AllocateDirectForwardChannelBands(edges, positionedNodes, layerBoundariesByNodeId, channels, direction); - - return channels; - } - - private static void AllocateDirectForwardChannelBands( - IReadOnlyCollection edges, - IReadOnlyDictionary positionedNodes, - IReadOnlyDictionary layerBoundariesByNodeId, - Dictionary channels, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return; - } - - var candidates = edges - .Select(edge => TryCreateDirectChannelCandidate(edge, positionedNodes, layerBoundariesByNodeId, channels)) - .Where(candidate => candidate is not null) - .Select(candidate => candidate!.Value) - .GroupBy(candidate => candidate.GapKey, StringComparer.Ordinal); - - foreach (var gapGroup in candidates) - { - var ordered = gapGroup - .OrderBy(candidate => candidate.TargetCenterY) - .ThenBy(candidate => candidate.TargetX) - .ThenBy(candidate => candidate.SourceCenterY) - .ThenBy(candidate => candidate.FamilyPriority) - .ThenBy(candidate => candidate.EdgeId, StringComparer.Ordinal) - .ToArray(); - if (ordered.Length == 0) - { - continue; - } - - var gapMinX = ordered.Max(candidate => candidate.GapMinX); - var gapMaxX = ordered.Min(candidate => candidate.GapMaxX); - if (gapMaxX - gapMinX < 24d) - { - continue; - } - - var edgePadding = Math.Min(28d, Math.Max(16d, (gapMaxX - gapMinX) * 0.12d)); - var usableMinX = gapMinX + edgePadding; - var usableMaxX = gapMaxX - edgePadding; - if (usableMaxX <= usableMinX) - { - usableMinX = gapMinX + 12d; - usableMaxX = gapMaxX - 12d; - } - - for (var index = 0; index < ordered.Length; index++) - { - var preferredX = ordered.Length == 1 - ? (usableMinX + usableMaxX) / 2d - : usableMinX + ((usableMaxX - usableMinX) * (index / (double)(ordered.Length - 1))); - preferredX = Clamp(preferredX, ordered[index].GapMinX + 8d, ordered[index].GapMaxX - 8d); - - if (!channels.TryGetValue(ordered[index].EdgeId, out var channel)) - { - continue; - } - - channels[ordered[index].EdgeId] = channel with - { - PreferredDirectChannelX = preferredX, - }; - } - } - } - - private static DirectChannelCandidate? TryCreateDirectChannelCandidate( - ElkEdge edge, - IReadOnlyDictionary positionedNodes, - IReadOnlyDictionary layerBoundariesByNodeId, - IReadOnlyDictionary channels) - { - if (!channels.TryGetValue(edge.Id, out var channel) || channel.RouteMode != EdgeRouteMode.Direct) - { - return null; - } - - var source = positionedNodes[edge.SourceNodeId]; - var target = positionedNodes[edge.TargetNodeId]; - var sourceCenterX = source.X + (source.Width / 2d); - var targetCenterX = target.X + (target.Width / 2d); - if (targetCenterX <= sourceCenterX + 1d) - { - return null; - } - - var sourceCenterY = source.Y + (source.Height / 2d); - var targetCenterY = target.Y + (target.Height / 2d); - if (Math.Abs(targetCenterY - sourceCenterY) < 56d) - { - return null; - } - - var sourceBoundary = ResolveLayerBoundary(edge.SourceNodeId, layerBoundariesByNodeId, source); - var targetBoundary = ResolveLayerBoundary(edge.TargetNodeId, layerBoundariesByNodeId, target); - var gapMinX = sourceBoundary.MaxX + 12d; - var gapMaxX = targetBoundary.MinX - 12d; - if (gapMaxX - gapMinX < 48d) - { - return null; - } - - var gapKey = $"{Math.Round(gapMinX, 2):0.##}|{Math.Round(gapMaxX, 2):0.##}"; - return new DirectChannelCandidate( - edge.Id, - gapKey, - gapMinX, - gapMaxX, - ResolveLaneFamilyPriority(edge.Label), - sourceCenterY, - targetCenterY, - target.X); - } - - private static string ResolveLaneFamilyKey(string? label) - { - if (string.IsNullOrWhiteSpace(label)) - { - return "default"; - } - - var normalized = label.Trim().ToLowerInvariant(); - if (normalized.Contains("failure", StringComparison.Ordinal)) - { - return "failure"; - } - - if (normalized.Contains("timeout", StringComparison.Ordinal)) - { - return "timeout"; - } - - if (normalized.StartsWith("repeat ", StringComparison.Ordinal) - || normalized.Equals("body", StringComparison.Ordinal)) - { - return "repeat"; - } - - if (normalized.StartsWith("when ", StringComparison.Ordinal)) - { - return "success"; - } - - if (normalized.Contains("otherwise", StringComparison.Ordinal) - || normalized.Contains("default", StringComparison.Ordinal)) - { - return "default"; - } - - if (normalized.Contains("missing condition", StringComparison.Ordinal)) - { - return "missing-condition"; - } - - return "default"; - } - - private static int ResolveLaneFamilyPriority(string? label) - { - return ResolveLaneFamilyKey(label) switch - { - "failure" => 0, - "timeout" => 1, - "repeat" => 2, - "default" => 3, - "success" => 4, - "missing-condition" => 5, - _ => 6, - }; - } - - private static int ResolveSinkLanePriority(string? label) - { - return ResolveLaneFamilyKey(label) switch - { - "default" => 0, - "success" => 1, - "repeat" => 2, - "timeout" => 3, - "failure" => 4, - "missing-condition" => 5, - _ => 6, - }; - } - - private static Dictionary BuildLayerBoundariesByNodeId( - IReadOnlyDictionary positionedNodes, - IReadOnlyDictionary layersByNodeId) - { - var boundariesByLayer = layersByNodeId - .Where(entry => positionedNodes.ContainsKey(entry.Key)) - .GroupBy(entry => entry.Value) - .ToDictionary( - group => group.Key, - group => - { - var nodes = group.Select(entry => positionedNodes[entry.Key]).ToArray(); - return new LayerBoundary( - nodes.Min(node => node.X), - nodes.Max(node => node.X + node.Width), - nodes.Min(node => node.Y), - nodes.Max(node => node.Y + node.Height)); - }); - - return layersByNodeId - .Where(entry => boundariesByLayer.ContainsKey(entry.Value)) - .ToDictionary(entry => entry.Key, entry => boundariesByLayer[entry.Value], StringComparer.Ordinal); - } - - private static LayerBoundary ResolveLayerBoundary( - string nodeId, - IReadOnlyDictionary layerBoundariesByNodeId, - ElkPositionedNode fallbackNode) - { - return layerBoundariesByNodeId.TryGetValue(nodeId, out var boundary) - ? boundary - : new LayerBoundary( - fallbackNode.X, - fallbackNode.X + fallbackNode.Width, - fallbackNode.Y, - fallbackNode.Y + fallbackNode.Height); - } - - private static ElkRoutedEdge[] AvoidNodeCrossings( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0) - { - return edges; - } - - const double margin = 10d; - var obstacles = nodes.Select(n => ( - Left: n.X - margin, Top: n.Y - margin, - Right: n.X + n.Width + margin, Bottom: n.Y + n.Height + margin, - Id: n.Id - )).ToArray(); - - var result = new ElkRoutedEdge[edges.Length]; - for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++) - { - var edge = edges[edgeIndex]; - var sourceId = edge.SourceNodeId ?? ""; - var targetId = edge.TargetNodeId ?? ""; - - var hasCrossing = false; - foreach (var section in edge.Sections) - { - var pts = new List { section.StartPoint }; - pts.AddRange(section.BendPoints); - pts.Add(section.EndPoint); - for (var i = 0; i < pts.Count - 1 && !hasCrossing; i++) - { - hasCrossing = SegmentCrossesObstacle(pts[i], pts[i + 1], obstacles, sourceId, targetId); - } - } - - if (!hasCrossing) - { - result[edgeIndex] = edge; - continue; - } - - var newSections = new List(edge.Sections.Count); - foreach (var section in edge.Sections) - { - var rerouted = RerouteWithGridAStar( - section.StartPoint, section.EndPoint, - obstacles, sourceId, targetId, margin); - - if (rerouted is not null && rerouted.Count >= 2) - { - newSections.Add(new ElkEdgeSection - { - StartPoint = rerouted[0], - EndPoint = rerouted[^1], - BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(), - }); - } - else - { - newSections.Add(section); - } - } - - result[edgeIndex] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - Label = edge.Label, - Sections = newSections, - }; - } - - return result; - } - - private static bool SegmentCrossesObstacle( - ElkPoint p1, ElkPoint p2, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, string targetId) - { - var isH = Math.Abs(p1.Y - p2.Y) < 2d; - var isV = Math.Abs(p1.X - p2.X) < 2d; - if (!isH && !isV) return false; - - foreach (var ob in obstacles) - { - if (ob.Id == sourceId || ob.Id == targetId) continue; - if (isH && p1.Y > ob.Top && p1.Y < ob.Bottom) - { - var minX = Math.Min(p1.X, p2.X); - var maxX = Math.Max(p1.X, p2.X); - if (maxX > ob.Left && minX < ob.Right) return true; - } - else if (isV && p1.X > ob.Left && p1.X < ob.Right) - { - var minY = Math.Min(p1.Y, p2.Y); - var maxY = Math.Max(p1.Y, p2.Y); - if (maxY > ob.Top && minY < ob.Bottom) return true; - } - } - - return false; - } - - private static List? RerouteWithGridAStar( - ElkPoint start, ElkPoint end, - (double Left, double Top, double Right, double Bottom, string Id)[] obstacles, - string sourceId, string targetId, - double margin) - { - var xs = new SortedSet { start.X, end.X }; - var ys = new SortedSet { start.Y, end.Y }; - foreach (var ob in obstacles) - { - if (ob.Id == sourceId || ob.Id == targetId) continue; - xs.Add(ob.Left - margin); - xs.Add(ob.Right + margin); - ys.Add(ob.Top - margin); - ys.Add(ob.Bottom + margin); - } - - var xArr = xs.ToArray(); - var yArr = ys.ToArray(); - var xCount = xArr.Length; - var yCount = yArr.Length; - if (xCount < 2 || yCount < 2) return null; - - var startIx = Array.BinarySearch(xArr, start.X); - var startIy = Array.BinarySearch(yArr, start.Y); - var endIx = Array.BinarySearch(xArr, end.X); - var endIy = Array.BinarySearch(yArr, end.Y); - if (startIx < 0 || startIy < 0 || endIx < 0 || endIy < 0) return null; - - bool IsBlocked(int ix1, int iy1, int ix2, int iy2) - { - var x1 = xArr[ix1]; var y1 = yArr[iy1]; - var x2 = xArr[ix2]; var y2 = yArr[iy2]; - foreach (var ob in obstacles) - { - if (ob.Id == sourceId || ob.Id == targetId) continue; - if (ix1 == ix2) - { - var segX = x1; - if (segX > ob.Left && segX < ob.Right) - { - var minY = Math.Min(y1, y2); - var maxY = Math.Max(y1, y2); - if (maxY > ob.Top && minY < ob.Bottom) return true; - } - } - else if (iy1 == iy2) - { - var segY = y1; - if (segY > ob.Top && segY < ob.Bottom) - { - var minX = Math.Min(x1, x2); - var maxX = Math.Max(x1, x2); - if (maxX > ob.Left && minX < ob.Right) return true; - } - } - } - - return false; - } - - // A* with (ix, iy, direction) state; direction: 0=none, 1=horizontal, 2=vertical - const double bendPenalty = 200d; - var stateCount = xCount * yCount * 3; - var gScore = new double[stateCount]; - Array.Fill(gScore, double.MaxValue); - var cameFrom = new int[stateCount]; - Array.Fill(cameFrom, -1); - - int StateId(int ix, int iy, int dir) => (ix * yCount + iy) * 3 + dir; - double Heuristic(int ix, int iy) => - Math.Abs(xArr[ix] - xArr[endIx]) + Math.Abs(yArr[iy] - yArr[endIy]); - - var startState = StateId(startIx, startIy, 0); - gScore[startState] = 0d; - var openSet = new PriorityQueue(); - openSet.Enqueue(startState, Heuristic(startIx, startIy)); - - var dx = new[] { 1, -1, 0, 0 }; - var dy = new[] { 0, 0, 1, -1 }; - var dirs = new[] { 1, 1, 2, 2 }; - - var maxIterations = xCount * yCount * 6; - var iterations = 0; - var closed = new HashSet(); - while (openSet.Count > 0 && iterations++ < maxIterations) - { - var current = openSet.Dequeue(); - - if (!closed.Add(current)) - { - continue; - } - - var curDir = current % 3; - var curIy = (current / 3) % yCount; - var curIx = (current / 3) / yCount; - - if (curIx == endIx && curIy == endIy) - { - var path = new List(); - var state = current; - while (state >= 0) - { - var sIy = (state / 3) % yCount; - var sIx = (state / 3) / yCount; - path.Add(new ElkPoint { X = xArr[sIx], Y = yArr[sIy] }); - state = cameFrom[state]; - } - - path.Reverse(); - var simplified = new List { path[0] }; - for (var i = 1; i < path.Count - 1; i++) - { - var prev = simplified[^1]; - var next = path[i + 1]; - if (Math.Abs(prev.X - path[i].X) > 0.5d || Math.Abs(path[i].X - next.X) > 0.5d) - { - if (Math.Abs(prev.Y - path[i].Y) > 0.5d || Math.Abs(path[i].Y - next.Y) > 0.5d) - { - simplified.Add(path[i]); - } - } - } - - simplified.Add(path[^1]); - return simplified; - } - - for (var d = 0; d < 4; d++) - { - var nx = curIx + dx[d]; - var ny = curIy + dy[d]; - if (nx < 0 || nx >= xCount || ny < 0 || ny >= yCount) continue; - if (IsBlocked(curIx, curIy, nx, ny)) continue; - - var newDir = dirs[d]; - var bend = (curDir != 0 && curDir != newDir) ? bendPenalty : 0d; - var dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]); - var tentativeG = gScore[current] + dist + bend; - var neighborState = StateId(nx, ny, newDir); - - if (tentativeG < gScore[neighborState]) - { - gScore[neighborState] = tentativeG; - cameFrom[neighborState] = current; - var f = tentativeG + Heuristic(nx, ny); - openSet.Enqueue(neighborState, f); - } - } - } - - return null; - } - - private static ElkRoutedEdge[] DistributeOverlappingPorts( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes, - ElkLayoutDirection direction) - { - if (direction != ElkLayoutDirection.LeftToRight) - { - return edges; - } - - var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); - var result = edges.ToArray(); - - var incomingByTarget = edges - .Select((e, i) => (Edge: e, Index: i)) - .Where(x => x.Edge.TargetNodeId is not null) - .GroupBy(x => x.Edge.TargetNodeId!, StringComparer.Ordinal) - .Where(g => g.Count() >= 2 && nodesById.ContainsKey(g.Key)); - - foreach (var group in incomingByTarget) - { - var targetNode = nodesById[group.Key]; - var edgesWithIndices = group.ToArray(); - - var sideGroups = edgesWithIndices - .GroupBy(x => - { - var sec = x.Edge.Sections.Last(); - var ep = sec.EndPoint; - if (Math.Abs(ep.X - targetNode.X) < 2d) return "WEST"; - if (Math.Abs(ep.X - (targetNode.X + targetNode.Width)) < 2d) return "EAST"; - if (Math.Abs(ep.Y - targetNode.Y) < 2d) return "NORTH"; - if (Math.Abs(ep.Y - (targetNode.Y + targetNode.Height)) < 2d) return "SOUTH"; - if (ep.X < targetNode.X + targetNode.Width / 2d) return "WEST"; - return "EAST"; - }); - - foreach (var sideGroup in sideGroups) - { - var items = sideGroup.OrderBy(x => - { - var sec = x.Edge.Sections.Last(); - var ep = sec.EndPoint; - return sideGroup.Key is "WEST" or "EAST" ? ep.Y : ep.X; - }).ToArray(); - - if (items.Length < 2) - { - continue; - } - - var hasOverlap = false; - for (var i = 1; i < items.Length; i++) - { - var prevEp = items[i - 1].Edge.Sections.Last().EndPoint; - var currEp = items[i].Edge.Sections.Last().EndPoint; - if (Math.Abs(prevEp.X - currEp.X) < 4d && Math.Abs(prevEp.Y - currEp.Y) < 4d) - { - hasOverlap = true; - break; - } - } - - if (!hasOverlap) - { - continue; - } - - var isVerticalSide = sideGroup.Key is "WEST" or "EAST"; - var sideExtent = isVerticalSide ? targetNode.Height : targetNode.Width; - var spacing = Math.Min(20d, (sideExtent - 12d) / Math.Max(1, items.Length)); - var totalSpread = (items.Length - 1) * spacing; - var startOffset = (sideExtent / 2d) - (totalSpread / 2d); - - for (var i = 0; i < items.Length; i++) - { - var edge = items[i].Edge; - var edgeIdx = items[i].Index; - var lastSection = edge.Sections.Last(); - var oldEnd = lastSection.EndPoint; - var offset = startOffset + (i * spacing); - - ElkPoint newEnd; - if (isVerticalSide) - { - newEnd = new ElkPoint { X = oldEnd.X, Y = targetNode.Y + offset }; - } - else - { - newEnd = new ElkPoint { X = targetNode.X + offset, Y = oldEnd.Y }; - } - - newEnd = new ElkPoint - { - X = Clamp(newEnd.X, targetNode.X, targetNode.X + targetNode.Width), - Y = Clamp(newEnd.Y, targetNode.Y + 6d, targetNode.Y + targetNode.Height - 6d), - }; - - var newBendPoints = lastSection.BendPoints.ToList(); - if (newBendPoints.Count > 0) - { - var lastBend = newBendPoints[^1]; - if (isVerticalSide && Math.Abs(lastBend.X - oldEnd.X) < 2d) - { - newBendPoints[^1] = new ElkPoint { X = lastBend.X, Y = newEnd.Y }; - } - else if (!isVerticalSide && Math.Abs(lastBend.Y - oldEnd.Y) < 2d) - { - newBendPoints[^1] = new ElkPoint { X = newEnd.X, Y = lastBend.Y }; - } - } - - var newSections = edge.Sections.ToList(); - newSections[^1] = new ElkEdgeSection - { - StartPoint = lastSection.StartPoint, - EndPoint = newEnd, - BendPoints = newBendPoints.ToArray(), - }; - - result[edgeIdx] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - Label = edge.Label, - Sections = newSections, - }; - } - } - } - - return result; - } - - private static ElkRoutedEdge[] SimplifyEdgePaths( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes) - { - var obstacles = nodes.Select(n => (L: n.X - 4d, T: n.Y - 4d, R: n.X + n.Width + 4d, B: n.Y + n.Height + 4d, Id: n.Id)).ToArray(); - var result = new ElkRoutedEdge[edges.Length]; - for (var i = 0; i < edges.Length; i++) - { - var edge = edges[i]; - var excludeIds = new HashSet(StringComparer.Ordinal) { edge.SourceNodeId ?? "", edge.TargetNodeId ?? "" }; - var anyChanged = false; - var newSections = new List(edge.Sections.Count); - - foreach (var section in edge.Sections) - { - var pts = new List { section.StartPoint }; - pts.AddRange(section.BendPoints); - pts.Add(section.EndPoint); - - // Pass 1: Remove collinear points - var cleaned = new List { pts[0] }; - for (var j = 1; j < pts.Count - 1; j++) - { - var prev = cleaned[^1]; - var curr = pts[j]; - var next = pts[j + 1]; - var sameX = Math.Abs(prev.X - curr.X) < 1d && Math.Abs(curr.X - next.X) < 1d; - var sameY = Math.Abs(prev.Y - curr.Y) < 1d && Math.Abs(curr.Y - next.Y) < 1d; - if (!sameX && !sameY) - { - cleaned.Add(curr); - } - } - cleaned.Add(pts[^1]); - - // Pass 2: Try L-shape shortcuts for each triple - var changed = true; - while (changed) - { - changed = false; - for (var j = 0; j + 2 < cleaned.Count; j++) - { - var a = cleaned[j]; - var c = cleaned[j + 2]; - var corner1 = new ElkPoint { X = a.X, Y = c.Y }; - var corner2 = new ElkPoint { X = c.X, Y = a.Y }; - - foreach (var corner in new[] { corner1, corner2 }) - { - if (SegmentClearsObstacles(a, corner, obstacles, excludeIds) - && SegmentClearsObstacles(corner, c, obstacles, excludeIds)) - { - cleaned[j + 1] = corner; - changed = true; - anyChanged = true; - break; - } - } - } - } - - newSections.Add(new ElkEdgeSection - { - StartPoint = cleaned[0], - EndPoint = cleaned[^1], - BendPoints = cleaned.Skip(1).Take(cleaned.Count - 2).ToArray(), - }); - } - - result[i] = anyChanged - ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } - : edge; - } - return result; - } - - private static bool SegmentClearsObstacles( - ElkPoint p1, ElkPoint p2, - (double L, double T, double R, double B, string Id)[] obstacles, - HashSet excludeIds) - { - var isH = Math.Abs(p1.Y - p2.Y) < 1d; - var isV = Math.Abs(p1.X - p2.X) < 1d; - if (!isH && !isV) return true; - - foreach (var ob in obstacles) - { - if (excludeIds.Contains(ob.Id)) continue; - if (isH && p1.Y > ob.T && p1.Y < ob.B) - { - if (Math.Max(p1.X, p2.X) > ob.L && Math.Min(p1.X, p2.X) < ob.R) return false; - } - else if (isV && p1.X > ob.L && p1.X < ob.R) - { - if (Math.Max(p1.Y, p2.Y) > ob.T && Math.Min(p1.Y, p2.Y) < ob.B) return false; - } - } - return true; - } - - private static ElkRoutedEdge[] SnapAnchorsToNodeBoundary( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes) - { - var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); - var result = new ElkRoutedEdge[edges.Length]; - for (var i = 0; i < edges.Length; i++) - { - var edge = edges[i]; - var anyChanged = false; - var newSections = edge.Sections.ToList(); - - for (var s = 0; s < newSections.Count; s++) - { - var section = newSections[s]; - var startFixed = false; - var endFixed = false; - var newStart = section.StartPoint; - var newEnd = section.EndPoint; - - if (edge.SourceNodeId is not null && nodesById.TryGetValue(edge.SourceNodeId, out var srcNode) && s == 0) - { - if (newStart.X > srcNode.X + 1d && newStart.X < srcNode.X + srcNode.Width - 1d - && newStart.Y > srcNode.Y + 1d && newStart.Y < srcNode.Y + srcNode.Height - 1d) - { - var target = section.BendPoints.Count > 0 ? section.BendPoints.First() : section.EndPoint; - newStart = ProjectOntoRectBoundary(srcNode, target); - startFixed = true; - } - } - - if (edge.TargetNodeId is not null && nodesById.TryGetValue(edge.TargetNodeId, out var tgtNode) && s == newSections.Count - 1) - { - if (newEnd.X > tgtNode.X + 1d && newEnd.X < tgtNode.X + tgtNode.Width - 1d - && newEnd.Y > tgtNode.Y + 1d && newEnd.Y < tgtNode.Y + tgtNode.Height - 1d) - { - var source = section.BendPoints.Count > 0 ? section.BendPoints.Last() : section.StartPoint; - newEnd = ProjectOntoRectBoundary(tgtNode, source); - endFixed = true; - } - } - - if (startFixed || endFixed) - { - anyChanged = true; - newSections[s] = new ElkEdgeSection - { - StartPoint = newStart, - EndPoint = newEnd, - BendPoints = section.BendPoints, - }; - } - } - - result[i] = anyChanged - ? new ElkRoutedEdge { Id = edge.Id, SourceNodeId = edge.SourceNodeId, TargetNodeId = edge.TargetNodeId, Label = edge.Label, Sections = newSections } - : edge; - } - return result; - } - - private static ElkPoint ProjectOntoRectBoundary(ElkPositionedNode node, ElkPoint toward) - { - var cx = node.X + node.Width / 2d; - var cy = node.Y + node.Height / 2d; - var hw = node.Width / 2d; - var hh = node.Height / 2d; - var dx = toward.X - cx; - var dy = toward.Y - cy; - - if (Math.Abs(dx) < 0.1d && Math.Abs(dy) < 0.1d) - { - return new ElkPoint { X = cx + hw, Y = cy }; - } - - var tMin = double.MaxValue; - if (dx > 0.1d) { var t = hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } - if (dx < -0.1d) { var t = -hw / dx; if (Math.Abs(dy * t) <= hh + 0.1d && t < tMin) tMin = t; } - if (dy > 0.1d) { var t = hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } - if (dy < -0.1d) { var t = -hh / dy; if (Math.Abs(dx * t) <= hw + 0.1d && t < tMin) tMin = t; } - - return tMin < double.MaxValue - ? new ElkPoint { X = cx + dx * tMin, Y = cy + dy * tMin } - : new ElkPoint { X = cx + hw, Y = cy }; - } - - private static ElkRoutedEdge[] TightenOuterCorridors( - ElkRoutedEdge[] edges, - ElkPositionedNode[] nodes) - { - if (nodes.Length == 0) return edges; - - var graphMinY = nodes.Min(n => n.Y); - var graphMaxY = nodes.Max(n => n.Y + n.Height); - const double minMargin = 16d; - const double laneGap = 10d; - - var outerEdges = new List<(int Index, double CorridorY, bool IsAbove)>(); - for (var i = 0; i < edges.Length; i++) - { - foreach (var section in edges[i].Sections) - { - var pts = new List { section.StartPoint }; - pts.AddRange(section.BendPoints); - pts.Add(section.EndPoint); - foreach (var pt in pts) - { - if (pt.Y < graphMinY - 8d) - { - outerEdges.Add((i, pt.Y, true)); - break; - } - if (pt.Y > graphMaxY + 8d) - { - outerEdges.Add((i, pt.Y, false)); - break; - } - } - } - } - - if (outerEdges.Count == 0) return edges; - - var aboveEdges = outerEdges.Where(e => e.IsAbove).OrderBy(e => e.CorridorY).ToArray(); - var belowEdges = outerEdges.Where(e => !e.IsAbove).OrderByDescending(e => e.CorridorY).ToArray(); - - var result = edges.ToArray(); - var shifts = new Dictionary(); - - for (var lane = 0; lane < aboveEdges.Length; lane++) - { - var targetY = graphMinY - minMargin - (lane * laneGap); - var currentY = aboveEdges[lane].CorridorY; - var shift = targetY - currentY; - if (Math.Abs(shift) > 2d) - { - shifts[aboveEdges[lane].Index] = shift; - } - } - - for (var lane = 0; lane < belowEdges.Length; lane++) - { - var targetY = graphMaxY + minMargin + (lane * laneGap); - var currentY = belowEdges[lane].CorridorY; - var shift = targetY - currentY; - if (Math.Abs(shift) > 2d) - { - shifts[belowEdges[lane].Index] = shift; - } - } - - foreach (var (edgeIndex, shift) in shifts) - { - var edge = result[edgeIndex]; - var boundary = shift > 0 ? graphMaxY : graphMinY; - var newSections = new List(); - foreach (var section in edge.Sections) - { - var newBendPoints = section.BendPoints.Select(bp => - { - if ((shift < 0 && bp.Y < graphMinY - 4d) || (shift > 0 && bp.Y > graphMaxY + 4d)) - { - return new ElkPoint { X = bp.X, Y = bp.Y + shift }; - } - return bp; - }).ToArray(); - - newSections.Add(new ElkEdgeSection - { - StartPoint = section.StartPoint, - EndPoint = section.EndPoint, - BendPoints = newBendPoints, - }); - } - - result[edgeIndex] = new ElkRoutedEdge - { - Id = edge.Id, - SourceNodeId = edge.SourceNodeId, - TargetNodeId = edge.TargetNodeId, - Label = edge.Label, - Sections = newSections, - }; - } - - return result; - } - - private static double Clamp(double value, double minimum, double maximum) - { - return Math.Min(Math.Max(value, minimum), maximum); - } - - private enum EdgeRouteMode - { - Direct = 0, - BackwardOuter = 1, - SinkOuter = 2, - SinkOuterTop = 3, - } - - private readonly record struct GraphBounds(double MinX, double MinY, double MaxX, double MaxY); - - private readonly record struct LayerBoundary(double MinX, double MaxX, double MinY, double MaxY); - - private readonly record struct EdgeChannel( - EdgeRouteMode RouteMode, - int BackwardLane, - int BackwardTargetIndex, - int BackwardTargetCount, - int ForwardIndex, - int ForwardCount, - int TargetIncomingIndex, - int TargetIncomingCount, - int SinkBandIndex, - int SinkBandCount, - double SharedOuterX, - double PreferredOuterY, - bool UseSourceCollector, - double PreferredDirectChannelX); - - private readonly record struct DirectChannelCandidate( - string EdgeId, - string GapKey, - double GapMinX, - double GapMaxX, - int FamilyPriority, - double SourceCenterY, - double TargetCenterY, - double TargetX); - - private static string NormalizeSide(string? side, ElkLayoutDirection direction) - { - if (string.IsNullOrWhiteSpace(side)) - { - return direction == ElkLayoutDirection.LeftToRight ? "EAST" : "SOUTH"; - } - - return side.Trim().ToUpperInvariant() switch - { - "LEFT" => "WEST", - "RIGHT" => "EAST", - "TOP" => "NORTH", - "BOTTOM" => "SOUTH", - var normalized => normalized, - }; - } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayoutInitialPlacement.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayoutInitialPlacement.cs new file mode 100644 index 000000000..efb8d9503 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayoutInitialPlacement.cs @@ -0,0 +1,248 @@ +namespace StellaOps.ElkSharp; + +internal static class ElkSharpLayoutInitialPlacement +{ + internal static void PlaceNodesLeftToRight( + Dictionary positionedNodes, ElkNode[][] layers, + DummyNodeResult dummyResult, Dictionary> augmentedIncoming, + Dictionary> augmentedOutgoing, Dictionary augmentedNodesById, + Dictionary> incomingNodeIds, Dictionary> outgoingNodeIds, + Dictionary nodesById, double adaptiveNodeSpacing, + ElkLayoutOptions options, int placementIterations) + { + var globalNodeHeight = augmentedNodesById.Values + .Where(n => !dummyResult.DummyNodeIds.Contains(n.Id)) + .Max(x => x.Height); + var edgeDensityFactor = adaptiveNodeSpacing / options.NodeSpacing; + var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)); + + var layerXPositions = new double[layers.Length]; + var currentX = 0d; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + layerXPositions[layerIndex] = currentX; + currentX += layers[layerIndex].Max(x => x.Width) + adaptiveLayerSpacing; + } + + var slotHeight = globalNodeHeight; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var layer = layers[layerIndex]; + var desiredY = new double[layer.Length]; + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var centers = new List(); + foreach (var srcId in augmentedIncoming[node.Id]) + { + if (positionedNodes.TryGetValue(srcId, out var srcPos)) + { + centers.Add(srcPos.Y + (srcPos.Height / 2d)); + } + } + + if (centers.Count > 0) + { + centers.Sort(); + var mid = centers.Count / 2; + var median = centers.Count % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + desiredY[nodeIndex] = median - (node.Height / 2d); + } + else + { + desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing); + } + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); + var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); + var pairSpacing = (prevIsDummy && currIsDummy) ? 2d + : (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) + : adaptiveNodeSpacing; + var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing; + if (desiredY[nodeIndex] < minY) + { + desiredY[nodeIndex] = minY; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + layer[nodeIndex], layerXPositions[layerIndex], desiredY[nodeIndex], options.Direction); + } + } + + var minNodeY = positionedNodes.Values.Min(n => n.Y); + if (minNodeY < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( + augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); + } + } + + ElkNodePlacement.RefineHorizontalPlacement(positionedNodes, layers, + incomingNodeIds, outgoingNodeIds, augmentedNodesById, + options.NodeSpacing, placementIterations, options.Direction); + + ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers, + dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds, + nodesById, options.NodeSpacing, options.Direction); + + ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers, + dummyResult.DummyNodeIds, incomingNodeIds, nodesById, + options.NodeSpacing, options.Direction); + + ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers, + dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds, + nodesById, options.NodeSpacing, options.Direction); + + ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers, + dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing, + augmentedNodesById, options.Direction); + + ElkNodePlacementAlignment.CenterMultiIncomingNodes( + positionedNodes, incomingNodeIds, nodesById, options.Direction); + + ElkNodePlacementAlignment.PropagateSuccessorPositionBackward( + positionedNodes, outgoingNodeIds, nodesById, options.Direction); + + minNodeY = positionedNodes.Values.Min(n => n.Y); + if (minNodeY < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( + augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction); + } + } + } + + internal static void PlaceNodesTopToBottom( + Dictionary positionedNodes, ElkNode[][] layers, + DummyNodeResult dummyResult, Dictionary> augmentedIncoming, + Dictionary> augmentedOutgoing, Dictionary augmentedNodesById, + Dictionary> incomingNodeIds, Dictionary> outgoingNodeIds, + Dictionary nodesById, double globalNodeWidth, + double adaptiveNodeSpacing, ElkLayoutOptions options, int placementIterations) + { + var layerYPositions = new double[layers.Length]; + var currentY = 0d; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + layerYPositions[layerIndex] = currentY; + currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing; + } + + var slotWidth = globalNodeWidth; + for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++) + { + var layer = layers[layerIndex]; + var desiredX = new double[layer.Length]; + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + var node = layer[nodeIndex]; + var centers = new List(); + foreach (var srcId in augmentedIncoming[node.Id]) + { + if (positionedNodes.TryGetValue(srcId, out var srcPos)) + { + centers.Add(srcPos.X + (srcPos.Width / 2d)); + } + } + + if (centers.Count > 0) + { + centers.Sort(); + var mid = centers.Count / 2; + var median = centers.Count % 2 == 1 + ? centers[mid] + : (centers[mid - 1] + centers[mid]) / 2d; + desiredX[nodeIndex] = median - (node.Width / 2d); + } + else + { + desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing); + } + } + + for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++) + { + var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id); + var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id); + var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d + : (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d) + : adaptiveNodeSpacing; + var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX; + if (desiredX[nodeIndex] < minX) + { + desiredX[nodeIndex] = minX; + } + } + + for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++) + { + positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode( + layer[nodeIndex], desiredX[nodeIndex], layerYPositions[layerIndex], options.Direction); + } + } + + var minNodeX = positionedNodes.Values.Min(n => n.X); + if (minNodeX < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( + augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); + } + } + + ElkNodePlacement.RefineVerticalPlacement(positionedNodes, layers, + incomingNodeIds, outgoingNodeIds, augmentedNodesById, + options.NodeSpacing, placementIterations, options.Direction); + + ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers, + dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds, + nodesById, options.NodeSpacing, options.Direction); + + ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers, + dummyResult.DummyNodeIds, incomingNodeIds, nodesById, + options.NodeSpacing, options.Direction); + + ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers, + dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds, + nodesById, options.NodeSpacing, options.Direction); + + ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers, + dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing, + augmentedNodesById, options.Direction); + + ElkNodePlacementAlignment.CenterMultiIncomingNodes( + positionedNodes, incomingNodeIds, nodesById, options.Direction); + + ElkNodePlacementAlignment.PropagateSuccessorPositionBackward( + positionedNodes, outgoingNodeIds, nodesById, options.Direction); + + minNodeX = positionedNodes.Values.Min(n => n.X); + if (minNodeX < -0.01d) + { + foreach (var nodeId in positionedNodes.Keys.ToArray()) + { + var pos = positionedNodes[nodeId]; + positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( + augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction); + } + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj b/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj index 1a44af860..7c68de5d6 100644 --- a/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj +++ b/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj @@ -6,4 +6,7 @@ $(NoWarn);CS8601;CS8602;CS8604 + + +