Improve rendering
This commit is contained in:
@@ -188,7 +188,7 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
],
|
],
|
||||||
trunkStyle,
|
trunkStyle,
|
||||||
collectorStrokeWidth,
|
collectorStrokeWidth,
|
||||||
highway.IsBackward ? null : trunkStyle.MarkerId,
|
trunkStyle.MarkerId,
|
||||||
collectorOpacity,
|
collectorOpacity,
|
||||||
highway.GroupId,
|
highway.GroupId,
|
||||||
IsCollector: true));
|
IsCollector: true));
|
||||||
@@ -251,7 +251,29 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var renderedPath = renderedPaths[pathIndex];
|
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)
|
var markerAttribute = string.IsNullOrWhiteSpace(renderedPath.MarkerId)
|
||||||
? string.Empty
|
? string.Empty
|
||||||
: $" marker-end=\"{renderedPath.MarkerId}\"";
|
: $" marker-end=\"{renderedPath.MarkerId}\"";
|
||||||
@@ -491,7 +513,7 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
<text x="{Format(placement.CenterX)}" y="{Format(placement.Top + 14)}"
|
<text x="{Format(placement.CenterX)}" y="{Format(placement.Top + 14)}"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-family="'Segoe UI', sans-serif"
|
font-family="'Segoe UI', sans-serif"
|
||||||
font-size="10.7"
|
font-size="11.5"
|
||||||
font-weight="700"
|
font-weight="700"
|
||||||
fill="{placement.Style.LabelText}">{Encode(placement.Label)}</text>
|
fill="{placement.Style.LabelText}">{Encode(placement.Label)}</text>
|
||||||
""");
|
""");
|
||||||
@@ -869,10 +891,11 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
double anchorY;
|
double anchorY;
|
||||||
if (isErrorLabel && points.Count >= 2)
|
if (isErrorLabel && points.Count >= 2)
|
||||||
{
|
{
|
||||||
var sourcePoint = points[0];
|
var longestSeg = ResolveLabelAnchorSegment(points);
|
||||||
var secondPoint = points[Math.Min(1, points.Count - 1)];
|
var segMidX = (longestSeg.Start.X + longestSeg.End.X) / 2d;
|
||||||
anchorX = (sourcePoint.X * 0.6d + secondPoint.X * 0.4d) + offsetX;
|
var segMidY = (longestSeg.Start.Y + longestSeg.End.Y) / 2d;
|
||||||
anchorY = (sourcePoint.Y * 0.6d + secondPoint.Y * 0.4d) + offsetY;
|
anchorX = segMidX + offsetX;
|
||||||
|
anchorY = segMidY + offsetY;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1256,9 +1279,10 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
group.First().FamilyKey,
|
group.First().FamilyKey,
|
||||||
isBackward);
|
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 =>
|
.Where(candidate =>
|
||||||
candidate.EdgeIds.Count >= (string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) ? 2 : 3))
|
candidate.EdgeIds.Count >= (candidate.IsBackward ? 3 : 2))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
foreach (var targetDirectionGroup in candidateGroups
|
foreach (var targetDirectionGroup in candidateGroups
|
||||||
@@ -1291,20 +1315,45 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
collectorY = preferredCollectorY
|
collectorY = preferredCollectorY
|
||||||
?? (targetNode.Y - 42d - ((orderedGroups.Length - 1) * 4d));
|
?? (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)
|
if (string.Equals(obstacleNode.Id, candidate.TargetId, StringComparison.Ordinal))
|
||||||
+ ResolveCenteredOffset(bandIndex, orderedGroups.Length, Math.Min(18d, targetNode.Width / 4d));
|
{
|
||||||
targetX = collectorX;
|
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
|
else
|
||||||
{
|
{
|
||||||
collectorX = sharedMaxX;
|
collectorX = sharedMaxX;
|
||||||
targetX = sharedMinX;
|
targetX = Math.Min(sharedMinX, targetNode.X + (targetNode.Width / 2d));
|
||||||
}
|
}
|
||||||
|
|
||||||
targetY = targetNode.Y;
|
targetY = targetNode.Y;
|
||||||
|
if (Math.Abs(collectorY - targetY) < 28d)
|
||||||
|
{
|
||||||
|
collectorY = targetY - 32d;
|
||||||
|
}
|
||||||
|
|
||||||
spreadPerEdge = 0d;
|
spreadPerEdge = 0d;
|
||||||
|
|
||||||
groups[groupId] = new HighwayGroup(
|
groups[groupId] = new HighwayGroup(
|
||||||
|
|||||||
@@ -8,39 +8,39 @@ using StellaOps.Workflow.Renderer.Svg;
|
|||||||
namespace StellaOps.Workflow.Renderer.Tests;
|
namespace StellaOps.Workflow.Renderer.Tests;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class AssistantPrintInsisDocumentsRenderingTests
|
public class DocumentProcessingWorkflowRenderingTests
|
||||||
{
|
{
|
||||||
private static WorkflowRenderGraph BuildAssistantPrintInsisDocumentsGraph()
|
private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph()
|
||||||
{
|
{
|
||||||
return new WorkflowRenderGraph
|
return new WorkflowRenderGraph
|
||||||
{
|
{
|
||||||
Id = "AssistantPrintInsisDocuments:1.0.0",
|
Id = "DocumentProcessingWorkflow:1.0.0",
|
||||||
Nodes =
|
Nodes =
|
||||||
[
|
[
|
||||||
new WorkflowRenderNode { Id = "start", Label = "Start", Kind = "Start", Width = 264, Height = 132 },
|
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/1", Label = "Initialize Context", 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/split", Label = "Parallel Execution", 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/2/join", Label = "Parallel Execution 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/3", Label = "Load Configuration", 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/4/batched", Label = "Setting:\nconfigParameters\nskipInternalNotification\nrecipientCount\nconfigHasBody\nconfigHasTitle", 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", Label = "Evaluate Conditions", 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", Label = "Internal Notification", 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", Label = "Internal Notification", 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/1/true/1/handled/1", Label = "Set internalNotificationFailed", 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", Label = "Has Recipients", 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", Label = "Email Dispatch", 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/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 = "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", Label = "Process Batch", 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/1/batched", Label = "Setting:\nbatchTimedOut\nbatchGenerateFailed\nhasMissingItems", 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", Label = "Execute Batch", 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", Label = "Retry Decision", 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/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 printGenerateFailed", Kind = "SetState", 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 = "Print Batch Returned Result", Kind = "Decision", Width = 188, Height = 132 },
|
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 = "Print Batch Succeeded", 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:\npolicyNo\nfiles\ndocsCount\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 },
|
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 printTimedOut", Kind = "SetState", Width = 208, Height = 88 },
|
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set batchTimedOut", Kind = "SetState", Width = 208, Height = 88 },
|
||||||
],
|
],
|
||||||
Edges =
|
Edges =
|
||||||
[
|
[
|
||||||
@@ -85,9 +85,9 @@ public class AssistantPrintInsisDocumentsRenderingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task AssistantPrintInsisDocuments_WhenLayoutOnly_ShouldProduceFinitePositions()
|
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
|
||||||
{
|
{
|
||||||
var graph = BuildAssistantPrintInsisDocumentsGraph();
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
@@ -102,9 +102,9 @@ public class AssistantPrintInsisDocumentsRenderingTests
|
|||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[Category("RenderingArtifacts")]
|
[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 engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
@@ -113,11 +113,11 @@ public class AssistantPrintInsisDocumentsRenderingTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||||
var svgDoc = svgRenderer.Render(layout, "AssistantPrintInsisDocuments [ElkSharp]");
|
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||||
|
|
||||||
var outputDir = Path.Combine(
|
var outputDir = Path.Combine(
|
||||||
Path.GetDirectoryName(typeof(AssistantPrintInsisDocumentsRenderingTests).Assembly.Location)!,
|
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "AssistantPrintInsisDocuments");
|
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
|
||||||
Directory.CreateDirectory(outputDir);
|
Directory.CreateDirectory(outputDir);
|
||||||
|
|
||||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
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 -->
|
<!-- ElkSharp is a ported ELK layout algorithm — suppress nullable warnings from the port -->
|
||||||
<NoWarn>$(NoWarn);CS8601;CS8602;CS8604</NoWarn>
|
<NoWarn>$(NoWarn);CS8601;CS8602;CS8604</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="**/*.ARCHIVED.cs" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user