Improve rendering
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
201
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelBands.cs
Normal file
201
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelBands.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
236
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelCorridors.cs
Normal file
236
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelCorridors.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
235
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.cs
Normal file
235
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
246
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs
Normal file
246
src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
295
src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs
Normal file
295
src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
159
src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorAStar.cs
Normal file
159
src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessorAStar.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
278
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs
Normal file
278
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
260
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAnchors.cs
Normal file
260
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterAnchors.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
274
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs
Normal file
274
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
97
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterGrouping.cs
Normal file
97
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterGrouping.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs
Normal file
43
src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
222
src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs
Normal file
222
src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
186
src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs
Normal file
186
src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs
Normal file
47
src/__Libraries/StellaOps.ElkSharp/ElkLayoutTypes.cs
Normal 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);
|
||||
107
src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs
Normal file
107
src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
264
src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs
Normal file
264
src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementAlignment.cs
Normal file
267
src/__Libraries/StellaOps.ElkSharp/ElkNodePlacementAlignment.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
191
src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs
Normal file
191
src/__Libraries/StellaOps.ElkSharp/ElkShapeBoundaries.cs
Normal 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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user