Add ElkSharp compound node support
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
using FluentAssertions;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ElkSharpCompoundLayoutTests
|
||||
{
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenCompoundParentIsUsedAsEdgeEndpoint_ShouldRejectTheGraph()
|
||||
{
|
||||
var engine = new ElkSharpLayeredLayoutEngine();
|
||||
var graph = new ElkGraph
|
||||
{
|
||||
Id = "invalid-compound-endpoint",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("group", "Group", width: 260, height: 180),
|
||||
CreateNode("child", "Child", parentNodeId: "group"),
|
||||
CreateNode("peer", "Peer"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new ElkEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "group",
|
||||
TargetNodeId = "peer",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var act = async () => await engine.LayoutAsync(graph);
|
||||
|
||||
await act.Should()
|
||||
.ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*leaf nodes only*");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSiblingsShareAParent_ShouldKeepThemAdjacentInLayerOrdering()
|
||||
{
|
||||
var engine = new ElkSharpLayeredLayoutEngine();
|
||||
var graph = new ElkGraph
|
||||
{
|
||||
Id = "compound-adjacency",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("start", "Start", kind: "Start", width: 90, height: 48),
|
||||
CreateNode("group", "Group", width: 260, height: 190),
|
||||
CreateNode("child-a", "Child A", parentNodeId: "group"),
|
||||
CreateNode("child-b", "Child B", parentNodeId: "group"),
|
||||
CreateNode("peer", "Peer"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("e-a", "start", "child-a"),
|
||||
CreateEdge("e-b", "start", "child-b"),
|
||||
CreateEdge("e-peer", "start", "peer"),
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph);
|
||||
var orderedLayer = result.Nodes
|
||||
.Where(node => node.Id is "child-a" or "child-b" or "peer")
|
||||
.OrderBy(node => node.Y)
|
||||
.Select(node => node.Id)
|
||||
.ToArray();
|
||||
|
||||
Math.Abs(Array.IndexOf(orderedLayer, "child-a") - Array.IndexOf(orderedLayer, "child-b"))
|
||||
.Should()
|
||||
.Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenCompoundParentWrapsChildren_ShouldRespectHeaderPaddingAndMinimumSize()
|
||||
{
|
||||
var engine = new ElkSharpLayeredLayoutEngine();
|
||||
var options = new ElkLayoutOptions
|
||||
{
|
||||
CompoundPadding = 24,
|
||||
CompoundHeaderHeight = 26,
|
||||
};
|
||||
var graph = new ElkGraph
|
||||
{
|
||||
Id = "compound-bounds",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("group", "Group", width: 220, height: 180),
|
||||
CreateNode("a", "A", parentNodeId: "group"),
|
||||
CreateNode("b", "B", parentNodeId: "group"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("e1", "a", "b"),
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, options);
|
||||
var group = result.Nodes.Single(node => node.Id == "group");
|
||||
var children = result.Nodes.Where(node => node.ParentNodeId == "group").ToArray();
|
||||
|
||||
var childMinX = children.Min(node => node.X);
|
||||
var childMaxX = children.Max(node => node.X + node.Width);
|
||||
var childMinY = children.Min(node => node.Y);
|
||||
var childMaxY = children.Max(node => node.Y + node.Height);
|
||||
|
||||
(childMinX - group.X).Should().BeGreaterThanOrEqualTo(options.CompoundPadding - 1d);
|
||||
(childMinY - group.Y).Should().BeGreaterThanOrEqualTo(options.CompoundPadding + options.CompoundHeaderHeight - 1d);
|
||||
((group.X + group.Width) - childMaxX).Should().BeGreaterThanOrEqualTo(options.CompoundPadding - 1d);
|
||||
((group.Y + group.Height) - childMaxY).Should().BeGreaterThanOrEqualTo(options.CompoundPadding - 1d);
|
||||
group.Width.Should().BeGreaterThanOrEqualTo(220d);
|
||||
group.Height.Should().BeGreaterThanOrEqualTo(180d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenNestedCompoundsExist_ShouldWrapNestedParentsBottomUp()
|
||||
{
|
||||
var engine = new ElkSharpLayeredLayoutEngine();
|
||||
var options = new ElkLayoutOptions
|
||||
{
|
||||
CompoundPadding = 20,
|
||||
CompoundHeaderHeight = 24,
|
||||
};
|
||||
var graph = new ElkGraph
|
||||
{
|
||||
Id = "nested-compounds",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("outer", "Outer", width: 260, height: 200),
|
||||
CreateNode("inner", "Inner", width: 220, height: 180, parentNodeId: "outer"),
|
||||
CreateNode("inner-a", "Inner A", parentNodeId: "inner"),
|
||||
CreateNode("inner-b", "Inner B", parentNodeId: "inner"),
|
||||
CreateNode("outer-leaf", "Outer Leaf", parentNodeId: "outer"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("e1", "outer-leaf", "inner-a"),
|
||||
CreateEdge("e2", "inner-a", "inner-b"),
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, options);
|
||||
var outer = result.Nodes.Single(node => node.Id == "outer");
|
||||
var inner = result.Nodes.Single(node => node.Id == "inner");
|
||||
var innerChildren = result.Nodes.Where(node => node.ParentNodeId == "inner").ToArray();
|
||||
|
||||
inner.X.Should().BeGreaterThanOrEqualTo(outer.X + options.CompoundPadding - 1d);
|
||||
inner.Y.Should().BeGreaterThanOrEqualTo(outer.Y + options.CompoundPadding + options.CompoundHeaderHeight - 1d);
|
||||
(inner.X + inner.Width).Should().BeLessThanOrEqualTo(outer.X + outer.Width - options.CompoundPadding + 1d);
|
||||
(inner.Y + inner.Height).Should().BeLessThanOrEqualTo(outer.Y + outer.Height - options.CompoundPadding + 1d);
|
||||
innerChildren.Should().NotBeEmpty();
|
||||
innerChildren.Should().OnlyContain(node =>
|
||||
node.X >= inner.X + options.CompoundPadding - 1d
|
||||
&& node.Y >= inner.Y + options.CompoundPadding + options.CompoundHeaderHeight - 1d
|
||||
&& node.X + node.Width <= inner.X + inner.Width - options.CompoundPadding + 1d
|
||||
&& node.Y + node.Height <= inner.Y + inner.Height - options.CompoundPadding + 1d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenCompoundGraphContainsEmptyParent_ShouldKeepTheEmptyParentVisible()
|
||||
{
|
||||
var engine = new ElkSharpLayeredLayoutEngine();
|
||||
var graph = new ElkGraph
|
||||
{
|
||||
Id = "compound-empty-parent",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("group", "Group", width: 240, height: 180),
|
||||
CreateNode("child", "Child", parentNodeId: "group"),
|
||||
CreateNode("empty", "Empty", width: 200, height: 120),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("e1", "child", "empty"),
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph);
|
||||
var empty = result.Nodes.Single(node => node.Id == "empty");
|
||||
|
||||
empty.Width.Should().Be(200d);
|
||||
empty.Height.Should().Be(120d);
|
||||
double.IsNaN(empty.X).Should().BeFalse();
|
||||
double.IsNaN(empty.Y).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenEdgeCrossesCompoundBoundaries_ShouldExposeBoundaryCrossingPoints()
|
||||
{
|
||||
var engine = new ElkSharpLayeredLayoutEngine();
|
||||
var graph = new ElkGraph
|
||||
{
|
||||
Id = "compound-boundary-crossings",
|
||||
Nodes =
|
||||
[
|
||||
CreateNode("source-group", "Source Group", width: 250, height: 190),
|
||||
CreateNode("source", "Source", parentNodeId: "source-group"),
|
||||
CreateNode("target-group", "Target Group", width: 250, height: 190),
|
||||
CreateNode("target", "Target", parentNodeId: "target-group"),
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge("e1", "source", "target"),
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph);
|
||||
var sourceGroup = result.Nodes.Single(node => node.Id == "source-group");
|
||||
var targetGroup = result.Nodes.Single(node => node.Id == "target-group");
|
||||
var edge = result.Edges.Single(routedEdge => routedEdge.Id == "e1");
|
||||
var path = BuildPath(edge);
|
||||
|
||||
path.Skip(1).SkipLast(1).Should().Contain(point => IsOnBoundary(point, sourceGroup));
|
||||
path.Skip(1).SkipLast(1).Should().Contain(point => IsOnBoundary(point, targetGroup));
|
||||
}
|
||||
|
||||
private static ElkNode CreateNode(
|
||||
string id,
|
||||
string label,
|
||||
string kind = "Task",
|
||||
double width = 160,
|
||||
double height = 72,
|
||||
string? parentNodeId = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Label = label,
|
||||
Kind = kind,
|
||||
Width = width,
|
||||
Height = height,
|
||||
ParentNodeId = parentNodeId,
|
||||
};
|
||||
|
||||
private static ElkEdge CreateEdge(string id, string sourceNodeId, string targetNodeId) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
SourceNodeId = sourceNodeId,
|
||||
TargetNodeId = targetNodeId,
|
||||
};
|
||||
|
||||
private static IReadOnlyList<ElkPoint> BuildPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var points = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
AppendPoint(points, section.StartPoint);
|
||||
foreach (var bendPoint in section.BendPoints)
|
||||
{
|
||||
AppendPoint(points, bendPoint);
|
||||
}
|
||||
|
||||
AppendPoint(points, section.EndPoint);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static void AppendPoint(ICollection<ElkPoint> points, ElkPoint point)
|
||||
{
|
||||
if (points.Count > 0 && points.Last() is { } previousPoint && AreClose(previousPoint, point))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
points.Add(point);
|
||||
}
|
||||
|
||||
private static bool IsOnBoundary(ElkPoint point, ElkPositionedNode node)
|
||||
{
|
||||
const double tolerance = 0.75d;
|
||||
var onLeftOrRight = Math.Abs(point.X - node.X) <= tolerance
|
||||
|| Math.Abs(point.X - (node.X + node.Width)) <= tolerance;
|
||||
var onTopOrBottom = Math.Abs(point.Y - node.Y) <= tolerance
|
||||
|| Math.Abs(point.Y - (node.Y + node.Height)) <= tolerance;
|
||||
var withinHorizontalSpan = point.X >= node.X - tolerance && point.X <= node.X + node.Width + tolerance;
|
||||
var withinVerticalSpan = point.Y >= node.Y - tolerance && point.Y <= node.Y + node.Height + tolerance;
|
||||
|
||||
return (onLeftOrRight && withinVerticalSpan) || (onTopOrBottom && withinHorizontalSpan);
|
||||
}
|
||||
|
||||
private static bool AreClose(ElkPoint left, ElkPoint right) =>
|
||||
Math.Abs(left.X - right.X) <= 0.01d
|
||||
&& Math.Abs(left.Y - right.Y) <= 0.01d;
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
## Local Rules
|
||||
- Preserve deterministic output for the same graph and options. Do not introduce random tie-breaking.
|
||||
- Keep orthogonal routing as the default contract unless a sprint explicitly broadens it.
|
||||
- `ParentNodeId` is supported only for strict compound trees. Real edges must terminate on leaves (including empty parents that have no children); non-leaf compound parents remain grouping-only containers in v1.
|
||||
- Compound layout keeps child coordinates absolute in the exported result. Parent rectangles are computed bottom-up from descendant bounds plus `CompoundPadding` and `CompoundHeaderHeight`, and compound-crossing edges must expose explicit parent-boundary points instead of silently skipping the border.
|
||||
- Treat channel assignment, dummy-edge reconstruction, and anchor selection as authoritative upstream inputs.
|
||||
- The current `Best`-effort path uses deterministic multi-strategy iterative routing after the baseline channel route. Keep strategy ordering stable and keep the seeded-random strategy family reproducible for the same graph.
|
||||
- A strategy attempt is only valid after final post-processing if it leaves no remaining broken short highways; detection alone is not enough.
|
||||
|
||||
188
src/__Libraries/StellaOps.ElkSharp/ElkCompoundHierarchy.cs
Normal file
188
src/__Libraries/StellaOps.ElkSharp/ElkCompoundHierarchy.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal sealed class ElkCompoundHierarchy
|
||||
{
|
||||
private const string RootKey = "\u0001__root__";
|
||||
|
||||
private readonly Dictionary<string, ElkNode> nodesById;
|
||||
private readonly Dictionary<string, string?> parentByNodeId;
|
||||
private readonly Dictionary<string, List<string>> childrenByParentId;
|
||||
private readonly Dictionary<string, string[]> ancestorsNearestFirstByNodeId;
|
||||
private readonly Dictionary<string, int> depthByNodeId;
|
||||
private readonly Dictionary<string, int> originalOrderByNodeId;
|
||||
|
||||
private ElkCompoundHierarchy(
|
||||
Dictionary<string, ElkNode> nodesById,
|
||||
Dictionary<string, string?> parentByNodeId,
|
||||
Dictionary<string, List<string>> childrenByParentId,
|
||||
Dictionary<string, string[]> ancestorsNearestFirstByNodeId,
|
||||
Dictionary<string, int> depthByNodeId,
|
||||
Dictionary<string, int> originalOrderByNodeId)
|
||||
{
|
||||
this.nodesById = nodesById;
|
||||
this.parentByNodeId = parentByNodeId;
|
||||
this.childrenByParentId = childrenByParentId;
|
||||
this.ancestorsNearestFirstByNodeId = ancestorsNearestFirstByNodeId;
|
||||
this.depthByNodeId = depthByNodeId;
|
||||
this.originalOrderByNodeId = originalOrderByNodeId;
|
||||
}
|
||||
|
||||
internal bool HasCompoundNodes =>
|
||||
nodesById.Keys.Any(IsCompoundNode);
|
||||
|
||||
internal IEnumerable<string> NodeIds => nodesById.Keys;
|
||||
|
||||
internal IEnumerable<string> NonLeafNodeIds =>
|
||||
nodesById.Keys.Where(IsCompoundNode);
|
||||
|
||||
internal static ElkCompoundHierarchy Build(IReadOnlyCollection<ElkNode> nodes)
|
||||
{
|
||||
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var originalOrderByNodeId = nodes
|
||||
.Select((node, index) => new KeyValuePair<string, int>(node.Id, index))
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
var parentByNodeId = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var childrenByParentId = new Dictionary<string, List<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
[RootKey] = [],
|
||||
};
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var parentNodeId = string.IsNullOrWhiteSpace(node.ParentNodeId)
|
||||
? null
|
||||
: node.ParentNodeId.Trim();
|
||||
if (parentNodeId is not null && !nodesById.ContainsKey(parentNodeId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Node '{node.Id}' references unknown parent '{parentNodeId}'.");
|
||||
}
|
||||
|
||||
if (string.Equals(parentNodeId, node.Id, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Node '{node.Id}' cannot be its own parent.");
|
||||
}
|
||||
|
||||
parentByNodeId[node.Id] = parentNodeId;
|
||||
var key = parentNodeId ?? RootKey;
|
||||
if (!childrenByParentId.TryGetValue(key, out var children))
|
||||
{
|
||||
children = [];
|
||||
childrenByParentId[key] = children;
|
||||
}
|
||||
|
||||
children.Add(node.Id);
|
||||
}
|
||||
|
||||
foreach (var children in childrenByParentId.Values)
|
||||
{
|
||||
children.Sort((left, right) => originalOrderByNodeId[left].CompareTo(originalOrderByNodeId[right]));
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal) { node.Id };
|
||||
var currentNodeId = node.Id;
|
||||
while (parentByNodeId[currentNodeId] is { } parentNodeId)
|
||||
{
|
||||
if (!visited.Add(parentNodeId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Containment cycle detected while resolving parent chain for '{node.Id}'.");
|
||||
}
|
||||
|
||||
currentNodeId = parentNodeId;
|
||||
}
|
||||
}
|
||||
|
||||
var ancestorsNearestFirstByNodeId = new Dictionary<string, string[]>(StringComparer.Ordinal);
|
||||
var depthByNodeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
ResolveAncestors(node.Id);
|
||||
}
|
||||
|
||||
return new ElkCompoundHierarchy(
|
||||
nodesById,
|
||||
parentByNodeId,
|
||||
childrenByParentId,
|
||||
ancestorsNearestFirstByNodeId,
|
||||
depthByNodeId,
|
||||
originalOrderByNodeId);
|
||||
|
||||
string[] ResolveAncestors(string nodeId)
|
||||
{
|
||||
if (ancestorsNearestFirstByNodeId.TryGetValue(nodeId, out var cachedAncestors))
|
||||
{
|
||||
return cachedAncestors;
|
||||
}
|
||||
|
||||
if (parentByNodeId[nodeId] is not { } parentNodeId)
|
||||
{
|
||||
depthByNodeId[nodeId] = 0;
|
||||
cachedAncestors = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
var parentAncestors = ResolveAncestors(parentNodeId);
|
||||
cachedAncestors = [parentNodeId, .. parentAncestors];
|
||||
depthByNodeId[nodeId] = depthByNodeId[parentNodeId] + 1;
|
||||
}
|
||||
|
||||
ancestorsNearestFirstByNodeId[nodeId] = cachedAncestors;
|
||||
return cachedAncestors;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool IsCompoundNode(string nodeId) =>
|
||||
childrenByParentId.TryGetValue(nodeId, out var children) && children.Count > 0;
|
||||
|
||||
internal bool IsLeafNode(string nodeId) =>
|
||||
!IsCompoundNode(nodeId);
|
||||
|
||||
internal bool IsLayoutVisibleNode(string nodeId) =>
|
||||
IsLeafNode(nodeId);
|
||||
|
||||
internal bool ContainsNode(string nodeId) =>
|
||||
nodesById.ContainsKey(nodeId);
|
||||
|
||||
internal int GetDepth(string nodeId) =>
|
||||
depthByNodeId[nodeId];
|
||||
|
||||
internal int GetOriginalOrder(string nodeId) =>
|
||||
originalOrderByNodeId[nodeId];
|
||||
|
||||
internal string? GetParentNodeId(string nodeId) =>
|
||||
parentByNodeId[nodeId];
|
||||
|
||||
internal IReadOnlyList<string> GetChildIds(string? parentNodeId)
|
||||
{
|
||||
var key = parentNodeId ?? RootKey;
|
||||
return childrenByParentId.TryGetValue(key, out var children)
|
||||
? children
|
||||
: [];
|
||||
}
|
||||
|
||||
internal IReadOnlyList<string> GetAncestorIdsNearestFirst(string nodeId) =>
|
||||
ancestorsNearestFirstByNodeId[nodeId];
|
||||
|
||||
internal IEnumerable<string> GetNonLeafNodeIdsByDescendingDepth() =>
|
||||
NonLeafNodeIds
|
||||
.OrderByDescending(GetDepth)
|
||||
.ThenBy(GetOriginalOrder);
|
||||
|
||||
internal string? GetLowestCommonAncestor(string leftNodeId, string rightNodeId)
|
||||
{
|
||||
var leftAncestors = new HashSet<string>(GetAncestorIdsNearestFirst(leftNodeId), StringComparer.Ordinal);
|
||||
foreach (var ancestorNodeId in GetAncestorIdsNearestFirst(rightNodeId))
|
||||
{
|
||||
if (leftAncestors.Contains(ancestorNodeId))
|
||||
{
|
||||
return ancestorNodeId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
857
src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs
Normal file
857
src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs
Normal file
@@ -0,0 +1,857 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkCompoundLayout
|
||||
{
|
||||
internal static ElkLayoutResult Layout(
|
||||
ElkGraph graph,
|
||||
ElkLayoutOptions options,
|
||||
ElkCompoundHierarchy hierarchy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nodesById = graph.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var visibleNodes = graph.Nodes
|
||||
.Where(node => hierarchy.IsLayoutVisibleNode(node.Id))
|
||||
.ToArray();
|
||||
var visibleNodesById = visibleNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var (inputOrder, backEdgeIds) = ElkLayerAssignment.BuildTraversalInputOrder(visibleNodes, graph.Edges, visibleNodesById);
|
||||
|
||||
var outgoing = visibleNodes.ToDictionary(node => node.Id, _ => new List<ElkEdge>(), StringComparer.Ordinal);
|
||||
var incomingNodeIds = visibleNodes.ToDictionary(node => node.Id, _ => new List<string>(), StringComparer.Ordinal);
|
||||
var outgoingNodeIds = visibleNodes.ToDictionary(node => node.Id, _ => new List<string>(), StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
outgoing[edge.SourceNodeId].Add(edge);
|
||||
incomingNodeIds[edge.TargetNodeId].Add(edge.SourceNodeId);
|
||||
outgoingNodeIds[edge.SourceNodeId].Add(edge.TargetNodeId);
|
||||
}
|
||||
|
||||
var layersByNodeId = ElkLayerAssignment.AssignLayersByInputOrder(visibleNodes, outgoing, inputOrder, backEdgeIds);
|
||||
var dummyResult = ElkLayerAssignment.InsertDummyNodes(visibleNodes, graph.Edges, layersByNodeId, inputOrder, backEdgeIds);
|
||||
var allNodes = dummyResult.AllNodes;
|
||||
var allEdges = dummyResult.AllEdges;
|
||||
var augmentedNodesById = allNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var augmentedInputOrder = dummyResult.AugmentedInputOrder;
|
||||
var augmentedIncoming = allNodes.ToDictionary(node => node.Id, _ => new List<string>(), StringComparer.Ordinal);
|
||||
var augmentedOutgoing = allNodes.ToDictionary(node => node.Id, _ => new List<string>(), StringComparer.Ordinal);
|
||||
foreach (var edge in allEdges)
|
||||
{
|
||||
augmentedIncoming[edge.TargetNodeId].Add(edge.SourceNodeId);
|
||||
augmentedOutgoing[edge.SourceNodeId].Add(edge.TargetNodeId);
|
||||
}
|
||||
|
||||
var orderingIterations = ElkNodePlacement.ResolveOrderingIterationCount(options, allEdges.Count, layersByNodeId.Count);
|
||||
var layers = allNodes
|
||||
.GroupBy(node => dummyResult.AugmentedLayers[node.Id])
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group => group.OrderBy(node => augmentedInputOrder[node.Id]).ToArray())
|
||||
.ToArray();
|
||||
layers = OptimizeLayerOrderingForHierarchy(
|
||||
layers,
|
||||
augmentedIncoming,
|
||||
augmentedOutgoing,
|
||||
augmentedInputOrder,
|
||||
orderingIterations,
|
||||
hierarchy,
|
||||
dummyResult.DummyNodeIds);
|
||||
|
||||
var placementIterations = ElkNodePlacement.ResolvePlacementIterationCount(options, allNodes.Count, layers.Length);
|
||||
var placementGrid = ElkNodePlacement.ResolvePlacementGrid(visibleNodes);
|
||||
var positionedVisibleNodes = new Dictionary<string, ElkPositionedNode>(StringComparer.Ordinal);
|
||||
var globalNodeWidth = visibleNodes.Max(node => node.Width);
|
||||
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, allEdges.Count - 15) * 0.02d));
|
||||
var adaptiveNodeSpacing = options.NodeSpacing * edgeDensityFactor;
|
||||
if (options.Direction == ElkLayoutDirection.LeftToRight)
|
||||
{
|
||||
ElkSharpLayoutInitialPlacement.PlaceNodesLeftToRight(
|
||||
positionedVisibleNodes,
|
||||
layers,
|
||||
dummyResult,
|
||||
augmentedIncoming,
|
||||
augmentedOutgoing,
|
||||
augmentedNodesById,
|
||||
incomingNodeIds,
|
||||
outgoingNodeIds,
|
||||
visibleNodesById,
|
||||
adaptiveNodeSpacing,
|
||||
options,
|
||||
placementIterations,
|
||||
placementGrid);
|
||||
}
|
||||
else
|
||||
{
|
||||
ElkSharpLayoutInitialPlacement.PlaceNodesTopToBottom(
|
||||
positionedVisibleNodes,
|
||||
layers,
|
||||
dummyResult,
|
||||
augmentedIncoming,
|
||||
augmentedOutgoing,
|
||||
augmentedNodesById,
|
||||
incomingNodeIds,
|
||||
outgoingNodeIds,
|
||||
visibleNodesById,
|
||||
globalNodeWidth,
|
||||
adaptiveNodeSpacing,
|
||||
options,
|
||||
placementIterations,
|
||||
placementGrid);
|
||||
}
|
||||
|
||||
var graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedVisibleNodes.Values
|
||||
.Where(node => !dummyResult.DummyNodeIds.Contains(node.Id))
|
||||
.ToArray());
|
||||
var layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedVisibleNodes, dummyResult.AugmentedLayers);
|
||||
var edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedVisibleNodes, options.Direction, layerBoundariesByNodeId);
|
||||
var reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges(
|
||||
graph.Edges,
|
||||
dummyResult,
|
||||
positionedVisibleNodes,
|
||||
augmentedNodesById,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
edgeChannels,
|
||||
layerBoundariesByNodeId);
|
||||
var routedEdges = graph.Edges
|
||||
.Select(edge =>
|
||||
{
|
||||
if (reconstructedEdges.TryGetValue(edge.Id, out var routed))
|
||||
{
|
||||
return routed;
|
||||
}
|
||||
|
||||
var channel = ElkSharpLayoutHelpers.ResolveSinkOverride(
|
||||
edgeChannels.GetValueOrDefault(edge.Id),
|
||||
edge.Id,
|
||||
dummyResult,
|
||||
edgeChannels,
|
||||
graph.Edges);
|
||||
return ElkEdgeRouter.RouteEdge(
|
||||
edge,
|
||||
visibleNodesById,
|
||||
positionedVisibleNodes,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
channel,
|
||||
layerBoundariesByNodeId);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
for (var gutterPass = 0; gutterPass < 3; gutterPass++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters(
|
||||
positionedVisibleNodes,
|
||||
routedEdges,
|
||||
dummyResult.AugmentedLayers,
|
||||
augmentedNodesById,
|
||||
options.LayerSpacing,
|
||||
options.Direction))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedVisibleNodes.Values
|
||||
.Where(node => !dummyResult.DummyNodeIds.Contains(node.Id))
|
||||
.ToArray());
|
||||
layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedVisibleNodes, dummyResult.AugmentedLayers);
|
||||
edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedVisibleNodes, options.Direction, layerBoundariesByNodeId);
|
||||
reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges(
|
||||
graph.Edges,
|
||||
dummyResult,
|
||||
positionedVisibleNodes,
|
||||
augmentedNodesById,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
edgeChannels,
|
||||
layerBoundariesByNodeId);
|
||||
routedEdges = graph.Edges
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
|
||||
? rerouted
|
||||
: ElkEdgeRouter.RouteEdge(
|
||||
edge,
|
||||
visibleNodesById,
|
||||
positionedVisibleNodes,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
ElkSharpLayoutHelpers.ResolveSinkOverride(
|
||||
edgeChannels.GetValueOrDefault(edge.Id),
|
||||
edge.Id,
|
||||
dummyResult,
|
||||
edgeChannels,
|
||||
graph.Edges),
|
||||
layerBoundariesByNodeId))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
for (var compactPass = 0; compactPass < 2; compactPass++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!ElkEdgeChannelGutters.CompactSparseVerticalCorridorGutters(
|
||||
positionedVisibleNodes,
|
||||
routedEdges,
|
||||
dummyResult.AugmentedLayers,
|
||||
augmentedNodesById,
|
||||
options.LayerSpacing,
|
||||
options.Direction))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedVisibleNodes.Values
|
||||
.Where(node => !dummyResult.DummyNodeIds.Contains(node.Id))
|
||||
.ToArray());
|
||||
layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedVisibleNodes, dummyResult.AugmentedLayers);
|
||||
edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedVisibleNodes, options.Direction, layerBoundariesByNodeId);
|
||||
reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges(
|
||||
graph.Edges,
|
||||
dummyResult,
|
||||
positionedVisibleNodes,
|
||||
augmentedNodesById,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
edgeChannels,
|
||||
layerBoundariesByNodeId);
|
||||
routedEdges = graph.Edges
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
|
||||
? rerouted
|
||||
: ElkEdgeRouter.RouteEdge(
|
||||
edge,
|
||||
visibleNodesById,
|
||||
positionedVisibleNodes,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
ElkSharpLayoutHelpers.ResolveSinkOverride(
|
||||
edgeChannels.GetValueOrDefault(edge.Id),
|
||||
edge.Id,
|
||||
dummyResult,
|
||||
edgeChannels,
|
||||
graph.Edges),
|
||||
layerBoundariesByNodeId))
|
||||
.ToArray();
|
||||
|
||||
if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters(
|
||||
positionedVisibleNodes,
|
||||
routedEdges,
|
||||
dummyResult.AugmentedLayers,
|
||||
augmentedNodesById,
|
||||
options.LayerSpacing,
|
||||
options.Direction))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedVisibleNodes.Values
|
||||
.Where(node => !dummyResult.DummyNodeIds.Contains(node.Id))
|
||||
.ToArray());
|
||||
layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedVisibleNodes, dummyResult.AugmentedLayers);
|
||||
edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedVisibleNodes, options.Direction, layerBoundariesByNodeId);
|
||||
reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges(
|
||||
graph.Edges,
|
||||
dummyResult,
|
||||
positionedVisibleNodes,
|
||||
augmentedNodesById,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
edgeChannels,
|
||||
layerBoundariesByNodeId);
|
||||
routedEdges = graph.Edges
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
|
||||
? rerouted
|
||||
: ElkEdgeRouter.RouteEdge(
|
||||
edge,
|
||||
visibleNodesById,
|
||||
positionedVisibleNodes,
|
||||
options.Direction,
|
||||
graphBounds,
|
||||
ElkSharpLayoutHelpers.ResolveSinkOverride(
|
||||
edgeChannels.GetValueOrDefault(edge.Id),
|
||||
edge.Id,
|
||||
dummyResult,
|
||||
edgeChannels,
|
||||
graph.Edges),
|
||||
layerBoundariesByNodeId))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var finalVisibleNodes = positionedVisibleNodes.Values
|
||||
.Where(node => !dummyResult.DummyNodeIds.Contains(node.Id))
|
||||
.OrderBy(node => inputOrder.GetValueOrDefault(node.Id, int.MaxValue))
|
||||
.ToArray();
|
||||
|
||||
routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalVisibleNodes);
|
||||
routedEdges = ElkEdgeRouterIterative.Optimize(routedEdges, finalVisibleNodes, options, cancellationToken);
|
||||
|
||||
var compoundNodes = BuildCompoundPositionedNodes(graph.Nodes, hierarchy, positionedVisibleNodes, options);
|
||||
if (TryResolveNegativeCoordinateShift(compoundNodes, out var shiftX, out var shiftY))
|
||||
{
|
||||
compoundNodes = ShiftNodes(graph.Nodes, compoundNodes, shiftX, shiftY, options.Direction);
|
||||
routedEdges = ShiftEdges(routedEdges, shiftX, shiftY);
|
||||
}
|
||||
|
||||
routedEdges = InsertCompoundBoundaryCrossings(routedEdges, compoundNodes, hierarchy);
|
||||
ElkLayoutDiagnostics.LogProgress("ElkSharp compound layout optimize returned");
|
||||
|
||||
return new ElkLayoutResult
|
||||
{
|
||||
GraphId = graph.Id,
|
||||
Nodes = graph.Nodes.Select(node => compoundNodes[node.Id]).ToArray(),
|
||||
Edges = routedEdges,
|
||||
};
|
||||
}
|
||||
|
||||
private static ElkNode[][] OptimizeLayerOrderingForHierarchy(
|
||||
ElkNode[][] initialLayers,
|
||||
IReadOnlyDictionary<string, List<string>> incomingNodeIds,
|
||||
IReadOnlyDictionary<string, List<string>> outgoingNodeIds,
|
||||
IReadOnlyDictionary<string, int> inputOrder,
|
||||
int iterationCount,
|
||||
ElkCompoundHierarchy hierarchy,
|
||||
IReadOnlySet<string> dummyNodeIds)
|
||||
{
|
||||
var layers = initialLayers
|
||||
.Select(layer => layer.ToList())
|
||||
.ToArray();
|
||||
var effectiveIterations = Math.Max(1, iterationCount);
|
||||
|
||||
for (var iteration = 0; iteration < effectiveIterations; iteration++)
|
||||
{
|
||||
for (var layerIndex = 1; layerIndex < layers.Length; layerIndex++)
|
||||
{
|
||||
OrderLayerWithHierarchy(layers, layerIndex, incomingNodeIds, inputOrder, hierarchy, dummyNodeIds);
|
||||
}
|
||||
|
||||
for (var layerIndex = layers.Length - 2; layerIndex >= 0; layerIndex--)
|
||||
{
|
||||
OrderLayerWithHierarchy(layers, layerIndex, outgoingNodeIds, inputOrder, hierarchy, dummyNodeIds);
|
||||
}
|
||||
}
|
||||
|
||||
return layers
|
||||
.Select(layer => layer.ToArray())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void OrderLayerWithHierarchy(
|
||||
IReadOnlyList<List<ElkNode>> layers,
|
||||
int layerIndex,
|
||||
IReadOnlyDictionary<string, List<string>> adjacentNodeIds,
|
||||
IReadOnlyDictionary<string, int> inputOrder,
|
||||
ElkCompoundHierarchy hierarchy,
|
||||
IReadOnlySet<string> dummyNodeIds)
|
||||
{
|
||||
var currentLayer = layers[layerIndex];
|
||||
if (currentLayer.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var positions = ElkNodeOrdering.BuildNodeOrderPositions(layers);
|
||||
var layerNodesById = currentLayer.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var realNodeIdsInLayer = currentLayer
|
||||
.Where(node => !dummyNodeIds.Contains(node.Id))
|
||||
.Select(node => node.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var orderedRealIds = OrderNodesForSubtree(
|
||||
null,
|
||||
hierarchy,
|
||||
realNodeIdsInLayer,
|
||||
adjacentNodeIds,
|
||||
positions,
|
||||
inputOrder);
|
||||
var orderedDummies = currentLayer
|
||||
.Where(node => dummyNodeIds.Contains(node.Id))
|
||||
.OrderBy(node => ElkNodeOrdering.ResolveOrderingRank(node.Id, adjacentNodeIds, positions))
|
||||
.ThenBy(node => positions[node.Id])
|
||||
.ThenBy(node => inputOrder[node.Id])
|
||||
.ToArray();
|
||||
|
||||
currentLayer.Clear();
|
||||
foreach (var nodeId in orderedRealIds)
|
||||
{
|
||||
currentLayer.Add(layerNodesById[nodeId]);
|
||||
}
|
||||
|
||||
currentLayer.AddRange(orderedDummies);
|
||||
}
|
||||
|
||||
private static List<string> OrderNodesForSubtree(
|
||||
string? parentNodeId,
|
||||
ElkCompoundHierarchy hierarchy,
|
||||
IReadOnlySet<string> realNodeIdsInLayer,
|
||||
IReadOnlyDictionary<string, List<string>> adjacentNodeIds,
|
||||
IReadOnlyDictionary<string, int> positions,
|
||||
IReadOnlyDictionary<string, int> inputOrder)
|
||||
{
|
||||
var blocks = new List<HierarchyOrderBlock>();
|
||||
foreach (var childNodeId in hierarchy.GetChildIds(parentNodeId))
|
||||
{
|
||||
if (realNodeIdsInLayer.Contains(childNodeId))
|
||||
{
|
||||
blocks.Add(BuildBlock([childNodeId]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hierarchy.IsCompoundNode(childNodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var descendantNodeIds = OrderNodesForSubtree(
|
||||
childNodeId,
|
||||
hierarchy,
|
||||
realNodeIdsInLayer,
|
||||
adjacentNodeIds,
|
||||
positions,
|
||||
inputOrder);
|
||||
if (descendantNodeIds.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.Add(BuildBlock(descendantNodeIds));
|
||||
}
|
||||
|
||||
return blocks
|
||||
.OrderBy(block => block.Rank)
|
||||
.ThenBy(block => block.MinCurrentPosition)
|
||||
.ThenBy(block => block.MinInputOrder)
|
||||
.SelectMany(block => block.NodeIds)
|
||||
.ToList();
|
||||
|
||||
HierarchyOrderBlock BuildBlock(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var ranks = new List<double>();
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
var nodeRank = ElkNodeOrdering.ResolveOrderingRank(nodeId, adjacentNodeIds, positions);
|
||||
if (!double.IsInfinity(nodeRank))
|
||||
{
|
||||
ranks.Add(nodeRank);
|
||||
}
|
||||
}
|
||||
|
||||
ranks.Sort();
|
||||
var rank = ranks.Count switch
|
||||
{
|
||||
0 => double.PositiveInfinity,
|
||||
_ when ranks.Count % 2 == 1 => ranks[ranks.Count / 2],
|
||||
_ => (ranks[(ranks.Count / 2) - 1] + ranks[ranks.Count / 2]) / 2d,
|
||||
};
|
||||
return new HierarchyOrderBlock(
|
||||
nodeIds,
|
||||
rank,
|
||||
nodeIds.Min(nodeId => positions.GetValueOrDefault(nodeId, int.MaxValue)),
|
||||
nodeIds.Min(nodeId => inputOrder.GetValueOrDefault(nodeId, int.MaxValue)));
|
||||
}
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] InsertCompoundBoundaryCrossings(
|
||||
IReadOnlyCollection<ElkRoutedEdge> routedEdges,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
|
||||
ElkCompoundHierarchy hierarchy)
|
||||
{
|
||||
return routedEdges
|
||||
.Select(edge =>
|
||||
{
|
||||
var path = ExtractPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
return edge;
|
||||
}
|
||||
|
||||
var lcaNodeId = hierarchy.GetLowestCommonAncestor(edge.SourceNodeId, edge.TargetNodeId);
|
||||
var sourceAncestorIds = hierarchy.GetAncestorIdsNearestFirst(edge.SourceNodeId)
|
||||
.TakeWhile(ancestorNodeId => !string.Equals(ancestorNodeId, lcaNodeId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
var targetAncestorIds = hierarchy.GetAncestorIdsNearestFirst(edge.TargetNodeId)
|
||||
.TakeWhile(ancestorNodeId => !string.Equals(ancestorNodeId, lcaNodeId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
if (sourceAncestorIds.Length == 0 && targetAncestorIds.Length == 0)
|
||||
{
|
||||
return edge;
|
||||
}
|
||||
|
||||
var rebuiltPath = path.ToList();
|
||||
var startSegmentIndex = 0;
|
||||
foreach (var ancestorNodeId in sourceAncestorIds)
|
||||
{
|
||||
if (!positionedNodes.TryGetValue(ancestorNodeId, out var ancestorNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryFindBoundaryTransitionFromStart(rebuiltPath, ancestorNode, startSegmentIndex, out var insertIndex, out var boundaryPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rebuiltPath.Insert(insertIndex, boundaryPoint);
|
||||
startSegmentIndex = insertIndex;
|
||||
}
|
||||
|
||||
var endSegmentIndex = rebuiltPath.Count - 2;
|
||||
foreach (var ancestorNodeId in targetAncestorIds)
|
||||
{
|
||||
if (!positionedNodes.TryGetValue(ancestorNodeId, out var ancestorNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryFindBoundaryTransitionFromEnd(rebuiltPath, ancestorNode, endSegmentIndex, out var insertIndex, out var boundaryPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rebuiltPath.Insert(insertIndex, boundaryPoint);
|
||||
endSegmentIndex = insertIndex - 2;
|
||||
}
|
||||
|
||||
return BuildEdgeWithPath(edge, rebuiltPath);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
AppendPoint(path, section.StartPoint);
|
||||
foreach (var bendPoint in section.BendPoints)
|
||||
{
|
||||
AppendPoint(path, bendPoint);
|
||||
}
|
||||
|
||||
AppendPoint(path, section.EndPoint);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge BuildEdgeWithPath(ElkRoutedEdge edge, IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
var normalizedPath = NormalizePath(path);
|
||||
if (normalizedPath.Count < 2)
|
||||
{
|
||||
return edge;
|
||||
}
|
||||
|
||||
return edge with
|
||||
{
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = normalizedPath[0],
|
||||
EndPoint = normalizedPath[^1],
|
||||
BendPoints = normalizedPath.Count > 2
|
||||
? normalizedPath.Skip(1).Take(normalizedPath.Count - 2).ToArray()
|
||||
: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryFindBoundaryTransitionFromStart(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
ElkPositionedNode boundaryNode,
|
||||
int startSegmentIndex,
|
||||
out int insertIndex,
|
||||
out ElkPoint boundaryPoint)
|
||||
{
|
||||
insertIndex = -1;
|
||||
boundaryPoint = default!;
|
||||
for (var segmentIndex = Math.Max(0, startSegmentIndex); segmentIndex < path.Count - 1; segmentIndex++)
|
||||
{
|
||||
var from = path[segmentIndex];
|
||||
var to = path[segmentIndex + 1];
|
||||
if (!IsInsideOrOn(boundaryNode, from) || IsInsideOrOn(boundaryNode, to))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryResolveBoundaryTransition(boundaryNode, from, to, exitBoundary: true, out boundaryPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
insertIndex = segmentIndex + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryFindBoundaryTransitionFromEnd(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
ElkPositionedNode boundaryNode,
|
||||
int startSegmentIndex,
|
||||
out int insertIndex,
|
||||
out ElkPoint boundaryPoint)
|
||||
{
|
||||
insertIndex = -1;
|
||||
boundaryPoint = default!;
|
||||
for (var segmentIndex = Math.Min(startSegmentIndex, path.Count - 2); segmentIndex >= 0; segmentIndex--)
|
||||
{
|
||||
var from = path[segmentIndex];
|
||||
var to = path[segmentIndex + 1];
|
||||
if (IsInsideOrOn(boundaryNode, from) || !IsInsideOrOn(boundaryNode, to))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryResolveBoundaryTransition(boundaryNode, from, to, exitBoundary: false, out boundaryPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
insertIndex = segmentIndex + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryResolveBoundaryTransition(
|
||||
ElkPositionedNode boundaryNode,
|
||||
ElkPoint from,
|
||||
ElkPoint to,
|
||||
bool exitBoundary,
|
||||
out ElkPoint boundaryPoint)
|
||||
{
|
||||
boundaryPoint = default!;
|
||||
if (!Clip(boundaryNode, from, to, out var enterScale, out var exitScale))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var scale = exitBoundary ? exitScale : enterScale;
|
||||
scale = Math.Clamp(scale, 0d, 1d);
|
||||
boundaryPoint = new ElkPoint
|
||||
{
|
||||
X = from.X + ((to.X - from.X) * scale),
|
||||
Y = from.Y + ((to.Y - from.Y) * scale),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Clip(
|
||||
ElkPositionedNode node,
|
||||
ElkPoint from,
|
||||
ElkPoint to,
|
||||
out double enterScale,
|
||||
out double exitScale)
|
||||
{
|
||||
enterScale = 0d;
|
||||
exitScale = 1d;
|
||||
|
||||
var deltaX = to.X - from.X;
|
||||
var deltaY = to.Y - from.Y;
|
||||
return ClipTest(-deltaX, from.X - node.X, ref enterScale, ref exitScale)
|
||||
&& ClipTest(deltaX, (node.X + node.Width) - from.X, ref enterScale, ref exitScale)
|
||||
&& ClipTest(-deltaY, from.Y - node.Y, ref enterScale, ref exitScale)
|
||||
&& ClipTest(deltaY, (node.Y + node.Height) - from.Y, ref enterScale, ref exitScale);
|
||||
|
||||
static bool ClipTest(double p, double q, ref double enter, ref double exit)
|
||||
{
|
||||
if (Math.Abs(p) <= 0.0001d)
|
||||
{
|
||||
return q >= -0.0001d;
|
||||
}
|
||||
|
||||
var ratio = q / p;
|
||||
if (p < 0d)
|
||||
{
|
||||
if (ratio > exit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ratio > enter)
|
||||
{
|
||||
enter = ratio;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ratio < enter)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ratio < exit)
|
||||
{
|
||||
exit = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInsideOrOn(ElkPositionedNode node, ElkPoint point)
|
||||
{
|
||||
const double tolerance = 0.01d;
|
||||
return point.X >= node.X - tolerance
|
||||
&& point.X <= node.X + node.Width + tolerance
|
||||
&& point.Y >= node.Y - tolerance
|
||||
&& point.Y <= node.Y + node.Height + tolerance;
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizePath(IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
var normalized = new List<ElkPoint>(path.Count);
|
||||
foreach (var point in path)
|
||||
{
|
||||
AppendPoint(normalized, point);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void AppendPoint(ICollection<ElkPoint> path, ElkPoint point)
|
||||
{
|
||||
if (path.Count > 0 && path.Last() is { } previousPoint && AreSamePoint(previousPoint, point))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
path.Add(new ElkPoint { X = point.X, Y = point.Y });
|
||||
}
|
||||
|
||||
private static bool AreSamePoint(ElkPoint left, ElkPoint right) =>
|
||||
Math.Abs(left.X - right.X) <= 0.01d
|
||||
&& Math.Abs(left.Y - right.Y) <= 0.01d;
|
||||
|
||||
private static Dictionary<string, ElkPositionedNode> BuildCompoundPositionedNodes(
|
||||
IReadOnlyCollection<ElkNode> graphNodes,
|
||||
ElkCompoundHierarchy hierarchy,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> positionedVisibleNodes,
|
||||
ElkLayoutOptions options)
|
||||
{
|
||||
var nodesById = graphNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var compoundNodes = new Dictionary<string, ElkPositionedNode>(StringComparer.Ordinal);
|
||||
foreach (var pair in positionedVisibleNodes)
|
||||
{
|
||||
if (!hierarchy.IsLayoutVisibleNode(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
compoundNodes[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
foreach (var nodeId in hierarchy.GetNonLeafNodeIdsByDescendingDepth())
|
||||
{
|
||||
var childBounds = hierarchy.GetChildIds(nodeId)
|
||||
.Select(childNodeId => compoundNodes[childNodeId])
|
||||
.ToArray();
|
||||
var contentLeft = childBounds.Min(node => node.X);
|
||||
var contentTop = childBounds.Min(node => node.Y);
|
||||
var contentRight = childBounds.Max(node => node.X + node.Width);
|
||||
var contentBottom = childBounds.Max(node => node.Y + node.Height);
|
||||
|
||||
var desiredWidth = (contentRight - contentLeft) + (options.CompoundPadding * 2d);
|
||||
var desiredHeight = (contentBottom - contentTop) + options.CompoundHeaderHeight + (options.CompoundPadding * 2d);
|
||||
var width = Math.Max(nodesById[nodeId].Width, desiredWidth);
|
||||
var height = Math.Max(nodesById[nodeId].Height, desiredHeight);
|
||||
var x = contentLeft - options.CompoundPadding - ((width - desiredWidth) / 2d);
|
||||
var y = contentTop - options.CompoundHeaderHeight - options.CompoundPadding - ((height - desiredHeight) / 2d);
|
||||
|
||||
compoundNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
|
||||
nodesById[nodeId],
|
||||
x,
|
||||
y,
|
||||
options.Direction) with
|
||||
{
|
||||
Width = width,
|
||||
Height = height,
|
||||
};
|
||||
}
|
||||
|
||||
return compoundNodes;
|
||||
}
|
||||
|
||||
private static bool TryResolveNegativeCoordinateShift(
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
|
||||
out double shiftX,
|
||||
out double shiftY)
|
||||
{
|
||||
shiftX = 0d;
|
||||
shiftY = 0d;
|
||||
if (positionedNodes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minX = positionedNodes.Values.Min(node => node.X);
|
||||
var minY = positionedNodes.Values.Min(node => node.Y);
|
||||
if (minX >= -0.01d && minY >= -0.01d)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shiftX = minX < 0d ? -minX : 0d;
|
||||
shiftY = minY < 0d ? -minY : 0d;
|
||||
return shiftX > 0d || shiftY > 0d;
|
||||
}
|
||||
|
||||
private static Dictionary<string, ElkPositionedNode> ShiftNodes(
|
||||
IReadOnlyCollection<ElkNode> sourceNodes,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> positionedNodes,
|
||||
double shiftX,
|
||||
double shiftY,
|
||||
ElkLayoutDirection direction)
|
||||
{
|
||||
var sourceNodesById = sourceNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
return positionedNodes.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair =>
|
||||
{
|
||||
var shifted = ElkLayoutHelpers.CreatePositionedNode(
|
||||
sourceNodesById[pair.Key],
|
||||
pair.Value.X + shiftX,
|
||||
pair.Value.Y + shiftY,
|
||||
direction);
|
||||
return shifted with
|
||||
{
|
||||
Width = pair.Value.Width,
|
||||
Height = pair.Value.Height,
|
||||
};
|
||||
},
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static ElkRoutedEdge[] ShiftEdges(
|
||||
IReadOnlyCollection<ElkRoutedEdge> routedEdges,
|
||||
double shiftX,
|
||||
double shiftY)
|
||||
{
|
||||
return routedEdges
|
||||
.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X + shiftX, Y = section.StartPoint.Y + shiftY },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X + shiftX, Y = section.EndPoint.Y + shiftY },
|
||||
BendPoints = section.BendPoints
|
||||
.Select(point => new ElkPoint { X = point.X + shiftX, Y = point.Y + shiftY })
|
||||
.ToArray(),
|
||||
}).ToArray(),
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private readonly record struct HierarchyOrderBlock(
|
||||
IReadOnlyList<string> NodeIds,
|
||||
double Rank,
|
||||
int MinCurrentPosition,
|
||||
int MinInputOrder);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkGraphValidator
|
||||
{
|
||||
internal static void ValidateGraph(ElkGraph graph)
|
||||
internal static ElkCompoundHierarchy ValidateGraph(ElkGraph graph)
|
||||
{
|
||||
if (graph.Nodes.Count == 0)
|
||||
{
|
||||
@@ -17,11 +17,7 @@ internal static class ElkGraphValidator
|
||||
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 hierarchy = ElkCompoundHierarchy.Build(graph.Nodes);
|
||||
var nodeIds = graph.Nodes.Select(x => x.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
@@ -29,7 +25,28 @@ internal static class ElkGraphValidator
|
||||
{
|
||||
throw new InvalidOperationException($"Edge '{edge.Id}' references an unknown node.");
|
||||
}
|
||||
|
||||
if (!hierarchy.IsLeafNode(edge.SourceNodeId) || !hierarchy.IsLeafNode(edge.TargetNodeId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Edge '{edge.Id}' must connect leaf nodes only when compound hierarchy is used.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var nodeId in hierarchy.NonLeafNodeIds)
|
||||
{
|
||||
if (!graph.Nodes.Any(node =>
|
||||
string.Equals(node.Id, nodeId, StringComparison.Ordinal)
|
||||
&& node.Ports.Count > 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Compound parent node '{nodeId}' cannot declare explicit ports in ElkSharp v1 compound layout.");
|
||||
}
|
||||
|
||||
return hierarchy;
|
||||
}
|
||||
|
||||
internal static GraphBounds ComputeGraphBounds(ICollection<ElkPositionedNode> nodes)
|
||||
|
||||
@@ -64,6 +64,8 @@ public sealed record ElkLayoutOptions
|
||||
public ElkLayoutDirection Direction { get; init; } = ElkLayoutDirection.LeftToRight;
|
||||
public double NodeSpacing { get; init; } = 40;
|
||||
public double LayerSpacing { get; init; } = 60;
|
||||
public double CompoundPadding { get; init; } = 30;
|
||||
public double CompoundHeaderHeight { get; init; } = 28;
|
||||
public ElkLayoutEffort Effort { get; init; } = ElkLayoutEffort.Best;
|
||||
public int? OrderingIterations { get; init; }
|
||||
public int? PlacementIterations { get; init; }
|
||||
|
||||
@@ -11,7 +11,11 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
options ??= new ElkLayoutOptions();
|
||||
ElkGraphValidator.ValidateGraph(graph);
|
||||
var hierarchy = ElkGraphValidator.ValidateGraph(graph);
|
||||
if (hierarchy.HasCompoundNodes)
|
||||
{
|
||||
return Task.FromResult(ElkCompoundLayout.Layout(graph, options, hierarchy, cancellationToken));
|
||||
}
|
||||
|
||||
var nodesById = graph.Nodes.ToDictionary(x => x.Id, StringComparer.Ordinal);
|
||||
var (inputOrder, backEdgeIds) = ElkLayerAssignment.BuildTraversalInputOrder(graph.Nodes, graph.Edges, nodesById);
|
||||
|
||||
Reference in New Issue
Block a user