Improve rendering

This commit is contained in:
master
2026-03-21 01:03:20 +02:00
parent d2e542f77e
commit eb27a69778
28 changed files with 9802 additions and 4490 deletions

View File

@@ -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
<text x="{Format(placement.CenterX)}" y="{Format(placement.Top + 14)}"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="10.7"
font-size="11.5"
font-weight="700"
fill="{placement.Style.LabelText}">{Encode(placement.Label)}</text>
""");
@@ -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(

View File

@@ -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");

View File

@@ -0,0 +1,201 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeChannelBands
{
internal static void AllocateDirectForwardChannelBands(
IReadOnlyCollection<ElkEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyDictionary<string, LayerBoundary> layerBoundariesByNodeId,
Dictionary<string, EdgeChannel> 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<string, ElkPositionedNode> positionedNodes,
IReadOnlyDictionary<string, LayerBoundary> layerBoundariesByNodeId,
IReadOnlyDictionary<string, EdgeChannel> 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);
}
}

View File

@@ -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<ElkEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> 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<ElkEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> 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<ElkPositionedNode> spanNodes,
double freeTop,
double freeBottom,
double candidate,
double desiredY,
IReadOnlyCollection<double> 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);
}
}

View File

@@ -0,0 +1,235 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeChannelGutters
{
internal static bool ExpandVerticalCorridorGutters(
Dictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyCollection<ElkRoutedEdge> routedEdges,
IReadOnlyDictionary<string, int> layersByNodeId,
IReadOnlyDictionary<string, ElkNode> 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<int, double>();
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<ElkPoint> { 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<string, ElkPositionedNode> positionedNodes,
IReadOnlyCollection<ElkRoutedEdge> routedEdges,
IReadOnlyDictionary<string, int> layersByNodeId,
IReadOnlyDictionary<string, ElkNode> 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<int, double>();
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<ElkPoint> { 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;
}
}

View File

@@ -0,0 +1,100 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeChannelSinkCorridors
{
internal static double ResolveSinkCorridorY(
IReadOnlyCollection<ElkEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyCollection<double> 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;
}
}

View File

@@ -0,0 +1,246 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeChannels
{
internal static Dictionary<string, EdgeChannel> ComputeEdgeChannels(
IReadOnlyCollection<ElkEdge> edges,
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
ElkLayoutDirection direction,
IReadOnlyDictionary<string, LayerBoundary> layerBoundariesByNodeId)
{
var channels = new Dictionary<string, EdgeChannel>(edges.Count, StringComparer.Ordinal);
var backwardEdges = new List<ElkEdge>();
var forwardEdgesBySource = new Dictionary<string, List<ElkEdge>>(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<double>();
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<string, List<ElkEdge>>(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<string, (int BandIndex, int BandCount, double SharedOuterX, double PreferredOuterY)>(StringComparer.Ordinal);
if (direction == ElkLayoutDirection.LeftToRight)
{
var reservedSinkBands = new List<double>(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;
}
}

View File

@@ -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<ElkPoint> { 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<ElkEdgeSection>(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<ElkEdgeSection>();
foreach (var section in edge.Sections)
{
var pts = new List<ElkPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
var fixedPts = new List<ElkPoint> { 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;
}
}

View File

@@ -0,0 +1,159 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgePostProcessorAStar
{
internal static List<ElkPoint>? 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<double> { start.X, end.X };
var ys = new SortedSet<double> { 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<int, double>();
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<int>();
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<ElkPoint>();
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<ElkPoint> { 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;
}
}

View File

@@ -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<ElkPoint> { 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<ElkPoint>();
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;
}
}

View File

@@ -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<string>(StringComparer.Ordinal) { edge.SourceNodeId ?? "", edge.TargetNodeId ?? "" };
var anyChanged = false;
var newSections = new List<ElkEdgeSection>(edge.Sections.Count);
var hasCorridor = ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY);
foreach (var section in edge.Sections)
{
var pts = new List<ElkPoint> { section.StartPoint };
pts.AddRange(section.BendPoints);
pts.Add(section.EndPoint);
// Pass 1: Remove collinear points
var cleaned = new List<ElkPoint> { 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<double>();
var belowYs = new List<double>();
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<int, double>();
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<ElkEdgeSection>();
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<string> 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<List<int>>();
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<ElkEdgeSection>();
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);
}
}
}
}

View File

@@ -0,0 +1,278 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeRouter
{
internal static ElkRoutedEdge RouteEdge(
ElkEdge edge,
IReadOnlyDictionary<string, ElkNode> nodesById,
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
ElkLayoutDirection direction,
GraphBounds graphBounds,
EdgeChannel channel,
IReadOnlyDictionary<string, LayerBoundary> 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<string, ElkRoutedEdge> ReconstructDummyEdges(
IReadOnlyCollection<ElkEdge> originalEdges,
DummyNodeResult dummyResult,
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyDictionary<string, ElkNode> augmentedNodesById,
ElkLayoutDirection direction,
GraphBounds graphBounds,
IReadOnlyDictionary<string, EdgeChannel> edgeChannels,
IReadOnlyDictionary<string, LayerBoundary> 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<string, ElkRoutedEdge>(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<ElkPoint>();
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<ElkEdge>? sourceGroup,
IReadOnlyList<ElkEdge>? targetGroup,
IReadOnlyDictionary<string, ElkPositionedNode> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,274 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeRouterBendPoints
{
internal static IReadOnlyCollection<ElkPoint> BuildHorizontalBendPoints(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
ElkPoint startPoint,
ElkPoint endPoint,
GraphBounds graphBounds,
EdgeChannel channel,
IReadOnlyDictionary<string, LayerBoundary> 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<ElkPoint> BuildVerticalBendPoints(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
ElkPoint startPoint,
ElkPoint endPoint,
GraphBounds graphBounds,
EdgeChannel channel,
IReadOnlyDictionary<string, LayerBoundary> 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<ElkPoint> BuildHorizontalBackwardBendPoints(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
ElkPoint startPoint,
ElkPoint endPoint,
GraphBounds graphBounds,
EdgeChannel channel,
IReadOnlyDictionary<string, LayerBoundary> 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<ElkPoint> BuildHorizontalSinkBendPoints(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
ElkPoint startPoint,
ElkPoint endPoint,
GraphBounds graphBounds,
EdgeChannel channel,
IReadOnlyDictionary<string, LayerBoundary> 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<ElkPoint> BuildHorizontalTopSinkBendPoints(
ElkPositionedNode sourceNode,
ElkPositionedNode targetNode,
ElkPoint startPoint,
ElkPoint endPoint,
GraphBounds graphBounds,
EdgeChannel channel,
IReadOnlyDictionary<string, LayerBoundary> 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 });
}
}

View File

@@ -0,0 +1,97 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeRouterGrouping
{
internal static double ResolveGroupedAnchorCoordinate(
ElkPositionedNode node,
ElkEdge edge,
IReadOnlyList<ElkEdge>? group,
IReadOnlyDictionary<string, ElkPositionedNode> 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<ElkEdge>? group,
IReadOnlyDictionary<string, ElkPositionedNode> 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;
}
}

View File

@@ -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<ElkPositionedNode> 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));
}
}

View File

@@ -0,0 +1,222 @@
namespace StellaOps.ElkSharp;
internal static class ElkLayerAssignment
{
internal static (Dictionary<string, int> InputOrder, HashSet<string> BackEdgeIds) BuildTraversalInputOrder(
IReadOnlyCollection<ElkNode> nodes,
IReadOnlyCollection<ElkEdge> edges,
IReadOnlyDictionary<string, ElkNode> nodesById)
{
var originalOrder = nodes
.Select((node, index) => new KeyValuePair<string, int>(node.Id, index))
.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal);
var outgoing = nodes.ToDictionary(node => node.Id, _ => new List<ElkEdge>(), 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<string>(nodes.Count);
var visited = new HashSet<string>(StringComparer.Ordinal);
var onStack = new HashSet<string>(StringComparer.Ordinal);
var backEdgeIds = new HashSet<string>(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<int>.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<int>.Default))
{
Visit(node.Id);
}
foreach (var endNode in nodes
.Where(node => string.Equals(node.Kind, "End", StringComparison.Ordinal))
.OrderBy(node => originalOrder[node.Id], Comparer<int>.Default))
{
Visit(endNode.Id);
}
var inputOrder = orderedNodeIds
.Select((nodeId, index) => new KeyValuePair<string, int>(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<int>.Default))
{
if (onStack.Contains(edge.TargetNodeId))
{
backEdgeIds.Add(edge.Id);
}
Visit(edge.TargetNodeId);
}
onStack.Remove(nodeId);
}
}
internal static Dictionary<string, int> AssignLayersByInputOrder(
IReadOnlyCollection<ElkNode> nodes,
IReadOnlyDictionary<string, List<ElkEdge>> outgoing,
IReadOnlyDictionary<string, int> inputOrder,
IReadOnlySet<string> backEdgeIds)
{
var layersByNodeId = nodes.ToDictionary(x => x.Id, _ => 0, StringComparer.Ordinal);
var orderedNodes = nodes
.OrderBy(node => inputOrder[node.Id], Comparer<int>.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<ElkNode> originalNodes,
IReadOnlyCollection<ElkEdge> originalEdges,
Dictionary<string, int> layersByNodeId,
IReadOnlyDictionary<string, int> inputOrder,
IReadOnlySet<string> backEdgeIds)
{
var allNodes = new List<ElkNode>(originalNodes);
var allEdges = new List<ElkEdge>();
var augmentedLayers = new Dictionary<string, int>(layersByNodeId, StringComparer.Ordinal);
var augmentedInputOrder = new Dictionary<string, int>(inputOrder, StringComparer.Ordinal);
var dummyNodeIds = new HashSet<string>(StringComparer.Ordinal);
var edgeDummyChains = new Dictionary<string, List<string>>(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<string>();
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);
}
}

View File

@@ -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<ElkPositionedPort> 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<ElkPositionedPort>(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<string, LayerBoundary> BuildLayerBoundariesByNodeId(
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyDictionary<string, int> 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<string, LayerBoundary> 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<ElkPoint> NormalizeBendPoints(params ElkPoint[] points)
{
if (points.Length == 0)
{
return [];
}
var normalized = new List<ElkPoint>(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,
};
}
}

View File

@@ -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<ElkNode> AllNodes,
List<ElkEdge> AllEdges,
Dictionary<string, int> AugmentedLayers,
Dictionary<string, int> AugmentedInputOrder,
HashSet<string> DummyNodeIds,
Dictionary<string, List<string>> EdgeDummyChains);

View File

@@ -0,0 +1,107 @@
namespace StellaOps.ElkSharp;
internal static class ElkNodeOrdering
{
internal static ElkNode[][] OptimizeLayerOrdering(
ElkNode[][] initialLayers,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, int> 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<List<ElkNode>> layers,
int layerIndex,
IReadOnlyDictionary<string, List<string>> adjacentNodeIds,
IReadOnlyDictionary<string, int> 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<string, int> BuildNodeOrderPositions(IReadOnlyList<List<ElkNode>> layers)
{
var positions = new Dictionary<string, int>(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<string, List<string>> adjacentNodeIds,
IReadOnlyDictionary<string, int> 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;
}
}

View File

@@ -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<string, ElkPositionedNode> positionedNodes,
IReadOnlyList<ElkNode[]> layers,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<string, ElkPositionedNode> positionedNodes,
IReadOnlyList<ElkNode[]> layers,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<string, ElkPositionedNode> positionedNodes,
IReadOnlyList<ElkNode[]> layers,
IReadOnlySet<string> dummyNodeIds,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<ElkNode> 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);
}
}
}

View File

@@ -0,0 +1,267 @@
namespace StellaOps.ElkSharp;
internal static class ElkNodePlacementAlignment
{
internal static void PropagateSuccessorPositionBackward(
Dictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<string> { 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<string, ElkPositionedNode> positionedNodes,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<string, ElkPositionedNode> positionedNodes,
IReadOnlyList<ElkNode[]> layers,
IReadOnlySet<string> dummyNodeIds,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, ElkPositionedNode> 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));
}
}

View File

@@ -0,0 +1,260 @@
namespace StellaOps.ElkSharp;
internal static class ElkNodePlacementPreferredCenter
{
internal static void AlignDummyNodesToFlow(
Dictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyList<ElkNode[]> layers,
IReadOnlySet<string> dummyNodeIds,
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkNode> 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<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, ElkPositionedNode> 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<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkPositionedNode> 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<double>();
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<string, List<string>> incomingNodeIds,
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
IReadOnlyDictionary<string, ElkPositionedNode> 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<double>();
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;
}
}

View File

@@ -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<ElkPoint> 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<ElkPoint> 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
namespace StellaOps.ElkSharp;
internal static class ElkSharpLayoutInitialPlacement
{
internal static void PlaceNodesLeftToRight(
Dictionary<string, ElkPositionedNode> positionedNodes, ElkNode[][] layers,
DummyNodeResult dummyResult, Dictionary<string, List<string>> augmentedIncoming,
Dictionary<string, List<string>> augmentedOutgoing, Dictionary<string, ElkNode> augmentedNodesById,
Dictionary<string, List<string>> incomingNodeIds, Dictionary<string, List<string>> outgoingNodeIds,
Dictionary<string, ElkNode> nodesById, double adaptiveNodeSpacing,
ElkLayoutOptions options, int placementIterations)
{
var globalNodeHeight = augmentedNodesById.Values
.Where(n => !dummyResult.DummyNodeIds.Contains(n.Id))
.Max(x => x.Height);
var edgeDensityFactor = adaptiveNodeSpacing / options.NodeSpacing;
var adaptiveLayerSpacing = options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d));
var layerXPositions = new double[layers.Length];
var currentX = 0d;
for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++)
{
layerXPositions[layerIndex] = currentX;
currentX += layers[layerIndex].Max(x => x.Width) + adaptiveLayerSpacing;
}
var slotHeight = globalNodeHeight;
for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++)
{
var layer = layers[layerIndex];
var desiredY = new double[layer.Length];
for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++)
{
var node = layer[nodeIndex];
var centers = new List<double>();
foreach (var srcId in augmentedIncoming[node.Id])
{
if (positionedNodes.TryGetValue(srcId, out var srcPos))
{
centers.Add(srcPos.Y + (srcPos.Height / 2d));
}
}
if (centers.Count > 0)
{
centers.Sort();
var mid = centers.Count / 2;
var median = centers.Count % 2 == 1
? centers[mid]
: (centers[mid - 1] + centers[mid]) / 2d;
desiredY[nodeIndex] = median - (node.Height / 2d);
}
else
{
desiredY[nodeIndex] = nodeIndex * (slotHeight + adaptiveNodeSpacing);
}
}
for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++)
{
var prevIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id);
var currIsDummy = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id);
var pairSpacing = (prevIsDummy && currIsDummy) ? 2d
: (prevIsDummy || currIsDummy) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d)
: adaptiveNodeSpacing;
var minY = desiredY[nodeIndex - 1] + layer[nodeIndex - 1].Height + pairSpacing;
if (desiredY[nodeIndex] < minY)
{
desiredY[nodeIndex] = minY;
}
}
for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++)
{
positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode(
layer[nodeIndex], layerXPositions[layerIndex], desiredY[nodeIndex], options.Direction);
}
}
var minNodeY = positionedNodes.Values.Min(n => n.Y);
if (minNodeY < -0.01d)
{
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction);
}
}
ElkNodePlacement.RefineHorizontalPlacement(positionedNodes, layers,
incomingNodeIds, outgoingNodeIds, augmentedNodesById,
options.NodeSpacing, placementIterations, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, nodesById,
options.NodeSpacing, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing,
augmentedNodesById, options.Direction);
ElkNodePlacementAlignment.CenterMultiIncomingNodes(
positionedNodes, incomingNodeIds, nodesById, options.Direction);
ElkNodePlacementAlignment.PropagateSuccessorPositionBackward(
positionedNodes, outgoingNodeIds, nodesById, options.Direction);
minNodeY = positionedNodes.Values.Min(n => n.Y);
if (minNodeY < -0.01d)
{
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
augmentedNodesById[nodeId], pos.X, pos.Y - minNodeY, options.Direction);
}
}
}
internal static void PlaceNodesTopToBottom(
Dictionary<string, ElkPositionedNode> positionedNodes, ElkNode[][] layers,
DummyNodeResult dummyResult, Dictionary<string, List<string>> augmentedIncoming,
Dictionary<string, List<string>> augmentedOutgoing, Dictionary<string, ElkNode> augmentedNodesById,
Dictionary<string, List<string>> incomingNodeIds, Dictionary<string, List<string>> outgoingNodeIds,
Dictionary<string, ElkNode> nodesById, double globalNodeWidth,
double adaptiveNodeSpacing, ElkLayoutOptions options, int placementIterations)
{
var layerYPositions = new double[layers.Length];
var currentY = 0d;
for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++)
{
layerYPositions[layerIndex] = currentY;
currentY += layers[layerIndex].Max(x => x.Height) + options.LayerSpacing;
}
var slotWidth = globalNodeWidth;
for (var layerIndex = 0; layerIndex < layers.Length; layerIndex++)
{
var layer = layers[layerIndex];
var desiredX = new double[layer.Length];
for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++)
{
var node = layer[nodeIndex];
var centers = new List<double>();
foreach (var srcId in augmentedIncoming[node.Id])
{
if (positionedNodes.TryGetValue(srcId, out var srcPos))
{
centers.Add(srcPos.X + (srcPos.Width / 2d));
}
}
if (centers.Count > 0)
{
centers.Sort();
var mid = centers.Count / 2;
var median = centers.Count % 2 == 1
? centers[mid]
: (centers[mid - 1] + centers[mid]) / 2d;
desiredX[nodeIndex] = median - (node.Width / 2d);
}
else
{
desiredX[nodeIndex] = nodeIndex * (slotWidth + adaptiveNodeSpacing);
}
}
for (var nodeIndex = 1; nodeIndex < layer.Length; nodeIndex++)
{
var prevIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex - 1].Id);
var currIsDummyX = dummyResult.DummyNodeIds.Contains(layer[nodeIndex].Id);
var pairSpacingX = (prevIsDummyX && currIsDummyX) ? 2d
: (prevIsDummyX || currIsDummyX) ? Math.Min(adaptiveNodeSpacing, options.NodeSpacing * 0.5d)
: adaptiveNodeSpacing;
var minX = desiredX[nodeIndex - 1] + layer[nodeIndex - 1].Width + pairSpacingX;
if (desiredX[nodeIndex] < minX)
{
desiredX[nodeIndex] = minX;
}
}
for (var nodeIndex = 0; nodeIndex < layer.Length; nodeIndex++)
{
positionedNodes[layer[nodeIndex].Id] = ElkLayoutHelpers.CreatePositionedNode(
layer[nodeIndex], desiredX[nodeIndex], layerYPositions[layerIndex], options.Direction);
}
}
var minNodeX = positionedNodes.Values.Min(n => n.X);
if (minNodeX < -0.01d)
{
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction);
}
}
ElkNodePlacement.RefineVerticalPlacement(positionedNodes, layers,
incomingNodeIds, outgoingNodeIds, augmentedNodesById,
options.NodeSpacing, placementIterations, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
ElkNodePlacementAlignment.CompactTowardIncomingFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, nodesById,
options.NodeSpacing, options.Direction);
ElkNodePlacement.SnapOriginalPrimaryAxes(positionedNodes, layers,
dummyResult.DummyNodeIds, incomingNodeIds, outgoingNodeIds,
nodesById, options.NodeSpacing, options.Direction);
ElkNodePlacementPreferredCenter.AlignDummyNodesToFlow(positionedNodes, layers,
dummyResult.DummyNodeIds, augmentedIncoming, augmentedOutgoing,
augmentedNodesById, options.Direction);
ElkNodePlacementAlignment.CenterMultiIncomingNodes(
positionedNodes, incomingNodeIds, nodesById, options.Direction);
ElkNodePlacementAlignment.PropagateSuccessorPositionBackward(
positionedNodes, outgoingNodeIds, nodesById, options.Direction);
minNodeX = positionedNodes.Values.Min(n => n.X);
if (minNodeX < -0.01d)
{
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var pos = positionedNodes[nodeId];
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
augmentedNodesById[nodeId], pos.X - minNodeX, pos.Y, options.Direction);
}
}
}
}

View File

@@ -6,4 +6,7 @@
<!-- ElkSharp is a ported ELK layout algorithm — suppress nullable warnings from the port -->
<NoWarn>$(NoWarn);CS8601;CS8602;CS8604</NoWarn>
</PropertyGroup>
<ItemGroup>
<Compile Remove="**/*.ARCHIVED.cs" />
</ItemGroup>
</Project>