From eb27a6977861a38edb20bc6f70dd98f53c635ae8 Mon Sep 17 00:00:00 2001
From: master <>
Date: Sat, 21 Mar 2026 01:03:20 +0200
Subject: [PATCH] Improve rendering
---
.../WorkflowRenderSvgRenderer.cs | 79 +-
...cumentProcessingWorkflowRenderingTests.cs} | 64 +-
.../StellaOps.ElkSharp/ElkEdgeChannelBands.cs | 201 +
.../ElkEdgeChannelCorridors.cs | 236 +
.../ElkEdgeChannelGutters.cs | 235 +
.../ElkEdgeChannelSinkCorridors.cs | 100 +
.../StellaOps.ElkSharp/ElkEdgeChannels.cs | 246 +
.../ElkEdgePostProcessor.cs | 295 +
.../ElkEdgePostProcessorAStar.cs | 159 +
.../ElkEdgePostProcessorCorridor.cs | 208 +
.../ElkEdgePostProcessorSimplify.cs | 334 ++
.../StellaOps.ElkSharp/ElkEdgeRouter.cs | 278 +
.../ElkEdgeRouterAnchors.cs | 260 +
.../ElkEdgeRouterBendPoints.cs | 274 +
.../ElkEdgeRouterGrouping.cs | 97 +
.../StellaOps.ElkSharp/ElkGraphValidator.cs | 43 +
.../StellaOps.ElkSharp/ElkLayerAssignment.cs | 222 +
.../StellaOps.ElkSharp/ElkLayoutHelpers.cs | 186 +
.../StellaOps.ElkSharp/ElkLayoutTypes.cs | 47 +
.../StellaOps.ElkSharp/ElkNodeOrdering.cs | 107 +
.../StellaOps.ElkSharp/ElkNodePlacement.cs | 264 +
.../ElkNodePlacementAlignment.cs | 267 +
.../ElkNodePlacementPreferredCenter.cs | 260 +
.../StellaOps.ElkSharp/ElkShapeBoundaries.cs | 191 +
.../ElkSharpLayeredLayoutEngine.ARCHIVED.cs | 4896 +++++++++++++++++
.../ElkSharpLayeredLayoutEngine.cs | 4492 +--------------
.../ElkSharpLayoutInitialPlacement.cs | 248 +
.../StellaOps.ElkSharp.csproj | 3 +
28 files changed, 9802 insertions(+), 4490 deletions(-)
rename src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/{AssistantPrintInsisDocumentsRenderingTests.cs => DocumentProcessingWorkflowRenderingTests.cs} (80%)
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelBands.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelCorridors.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelSinkCorridors.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorAStar.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorCorridor.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorSimplify.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAnchors.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterGrouping.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementAlignment.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementPreferredCenter.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.ARCHIVED.cs
create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkSharpLayoutInitialPlacement.cs
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