Add ElkSharp compound node support

This commit is contained in:
master
2026-03-28 13:36:52 +02:00
parent 7748c75934
commit 717316d5a0
9 changed files with 1451 additions and 8 deletions

View File

@@ -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.

View 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;
}
}

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

View File

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

View File

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

View File

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