From 717316d5a09ee61f1a900e60911120f4e14394db Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 28 Mar 2026 13:36:52 +0200 Subject: [PATCH] Add ElkSharp compound node support --- ..._003_ElkSharp_compound_sugiyama_support.md | 84 ++ docs/workflow/ENGINE.md | 2 +- .../ElkSharpCompoundLayoutTests.cs | 289 ++++++ src/__Libraries/StellaOps.ElkSharp/AGENTS.md | 2 + .../ElkCompoundHierarchy.cs | 188 ++++ .../StellaOps.ElkSharp/ElkCompoundLayout.cs | 857 ++++++++++++++++++ .../StellaOps.ElkSharp/ElkGraphValidator.cs | 29 +- .../StellaOps.ElkSharp/ElkModels.cs | 2 + .../ElkSharpLayeredLayoutEngine.cs | 6 +- 9 files changed, 1451 insertions(+), 8 deletions(-) create mode 100644 docs/implplan/SPRINT_20260328_003_ElkSharp_compound_sugiyama_support.md create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpCompoundLayoutTests.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkCompoundHierarchy.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs diff --git a/docs/implplan/SPRINT_20260328_003_ElkSharp_compound_sugiyama_support.md b/docs/implplan/SPRINT_20260328_003_ElkSharp_compound_sugiyama_support.md new file mode 100644 index 000000000..2608b567c --- /dev/null +++ b/docs/implplan/SPRINT_20260328_003_ElkSharp_compound_sugiyama_support.md @@ -0,0 +1,84 @@ +# Sprint 20260328-003 - ElkSharp Compound Sugiyama Support + +## Topic & Scope +- Extend the native ElkSharp layered engine to support true compound nodes using the existing `ParentNodeId` model contract. +- Keep the current layered pipeline shape for flat graphs while adding a hierarchy-aware path for compound graphs. +- Working directory: `src/__Libraries/StellaOps.ElkSharp/`. +- Expected evidence: focused ElkSharp compound-layout tests, flat-layout regression coverage, and workflow engine docs updated for compound-node support. + +## Dependencies & Concurrency +- Depends on the current ElkSharp layered pipeline in `src/__Libraries/StellaOps.ElkSharp/`. +- Safe cross-module edits for this sprint are limited to: + - `src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/` + - `docs/workflow/` + +## Documentation Prerequisites +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/code-of-conduct/TESTING_PRACTICES.md` +- `docs/workflow/ENGINE.md` +- `src/__Libraries/StellaOps.ElkSharp/AGENTS.md` +- `src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs` +- `src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs` +- `src/__Libraries/StellaOps.ElkSharp/ElkLayerAssignment.cs` +- `src/__Libraries/StellaOps.ElkSharp/ElkNodeOrdering.cs` +- `src/__Libraries/StellaOps.ElkSharp/ElkNodePlacement.cs` +- `src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs` + +## Delivery Tracker + +### TASK-001 - Add compound hierarchy validation and metadata +Status: DONE +Dependency: none +Owners: Implementer +Task description: +- Replace the flat-only `ParentNodeId` rejection with strict compound validation for parent existence, cycle-free containment, and leaf-only real edges. +- Add reusable internal hierarchy metadata so the layered engine can reason about top-level groups, descendants, ancestors, and lowest common ancestors without duplicating tree traversal logic. + +Completion criteria: +- [x] Compound graphs with valid `ParentNodeId` trees are accepted +- [x] Invalid parent references, containment cycles, and non-leaf edge endpoints are rejected deterministically +- [x] Internal hierarchy helpers expose the data needed by ordering, placement, and routing + +### TASK-002 - Implement hierarchy-aware layered ordering and parent placement +Status: DONE +Dependency: TASK-001 +Owners: Implementer +Task description: +- Keep flat graphs on the existing path, but add a compound-aware path that layers and routes visible leaves while keeping siblings contiguous in each layer ordering. +- Compute non-empty parent bounds bottom-up from child bounds plus header and padding, while preserving absolute child coordinates in the final result. + +Completion criteria: +- [x] Flat graphs still use the existing path unchanged +- [x] Compound children of the same parent stay adjacent in the ordered layers +- [x] Parent bounds wrap descendants with header and padding and respect minimum declared width and height + +### TASK-003 - Add compound boundary crossings, tests, and docs +Status: DONE +Dependency: TASK-002 +Owners: Implementer +Task description: +- Insert explicit parent-boundary crossings for leaf-to-leaf edges that leave or enter compounds, keeping edges from silently skipping compound borders. +- Add focused compound-layout tests and update workflow engine docs and ElkSharp guidance to describe the supported compound contract. + +Completion criteria: +- [x] Routed edges that cross compound boundaries expose explicit boundary-crossing points +- [x] Focused ElkSharp tests cover empty parents, nested parents, parent bounds, and invalid compound input +- [x] `docs/workflow/ENGINE.md` and `src/__Libraries/StellaOps.ElkSharp/AGENTS.md` document compound-node support and v1 limits + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-28 | Sprint created and work started for native ElkSharp compound-node support using the existing `ParentNodeId` contract. | Implementer | +| 2026-03-28 | Added compound hierarchy validation, compound-aware layered placement/routing, focused public-surface tests, and contract documentation updates. | Implementer | +| 2026-03-28 | `ElkSharpCompoundLayoutTests` passed (6/6). Full renderer project still reproduces the pre-existing post-artifact `testhost` hang, and a separate flat-layout regression slice in `ElkSharpWorkflowRenderLayoutEngineTests` currently fails on this branch. | Implementer | + +## Decisions & Risks +- The compound rollout is limited to strict containment trees and leaf-to-leaf real edges in v1. Parent endpoints remain invalid input. +- Flat-graph behavior must remain stable when no node sets `ParentNodeId`. +- Compound routing will start by adding explicit ancestor boundary crossings on top of the existing routed leaf paths so the engine can ship usable compound support without rewriting the entire router in one step. +- Broader renderer verification on the current branch is not yet fully green: `ElkSharpCompoundLayoutTests` pass, the document-processing artifact is regenerated successfully, but the full renderer project still leaves a hung `testhost` after the artifact phase and `ElkSharpWorkflowRenderLayoutEngineTests` currently reports four flat-layout failures that need separate triage. + +## Next Checkpoints +- After TASK-001: focused validator and hierarchy tests +- After TASK-002: compound ordering and parent-bounds tests +- After TASK-003: targeted ElkSharp renderer test project run and workflow docs update diff --git a/docs/workflow/ENGINE.md b/docs/workflow/ENGINE.md index 702c4a251..49bdb62cf 100644 --- a/docs/workflow/ENGINE.md +++ b/docs/workflow/ENGINE.md @@ -920,7 +920,7 @@ The engine can render workflow definitions as visual diagrams. | Engine | Description | |--------|-------------| -| **ElkSharp** | Port of Eclipse Layout Kernel (default). In `Best` effort mode it runs a deterministic iterative multi-strategy orthogonal router after base routing, scoring candidate layouts across crossings, proximity, labels, target-approach joins, detours, target-approach backtracking, and entry geometry before selecting the best valid result. Attempt 1 remains the only full-strategy reroute; later attempts repair only the currently penalized lanes or exact conflict peers, with shortest-path detours prioritized first, a direct orthogonal shortcut tried before broader rerouting, and corridor-like overshoots only eligible when a clean orthogonal shortcut actually exists. Local-repair candidate building may run in parallel inside an attempt, but builds that touch the same source/target neighborhood are lock-serialized and the final apply order remains deterministic. Small or protected graphs keep the baseline route to preserve established sink-corridor, backward-edge, and port-anchor contracts, while larger congested graphs use the iterative sweep. Final strategy acceptance re-validates post-processed output so remaining broken short highways and non-applicable target-side approach joins are retried instead of being selected, while other soft-rule regressions get bounded multi-attempt retries and a wider but finite strategy sweep before fallback selection. The current A* pathfinder precomputes node-obstacle blocked step masks per route and uses lighter soft-obstacle rejection checks before exact geometry tests, materially reducing route-all-edges time without changing selected-path semantics. A final cheap geometry-repair pass cleans node-side entry/exit angles, target-slot spacing, repeat-collector return lanes, and target-side backtracking without re-running whole-graph A*. Rectangular boundary joins are constrained to a discrete slot lattice so one edge cannot silently concentrate on top of another: `left`/`right` faces may use at most `3` evenly spread side slots, `top`/`bottom` faces may use at most `5`, and the realized slot span matches the same safe boundary inset used by rectangle entry/exit normalization. Gateway faces are limited to `1` centered slot or `2` centered slots, singleton entries and preserved repeat/corridor exits are scored against the same centered lattice instead of being exempt, and the final slot snap can relax the generic shared-lane validator when the centered repair is still obstacle-safe and boundary-valid. Winner refinement now ends with a boundary-slot restabilization pass as well, so late shared-lane or under-node cleanup cannot drift decision/branch source exits back off the assigned lattice. Shortest-path local repair now also reuses interior axes from the current path and tries a raw-clearance obstacle-skirt fallback before accepting a wider preserved overshoot, which lets detour cleanup collapse onto an honest existing lane when the expanded-clearance candidates stay unnecessarily high. Decision/Fork/Join gateway nodes use a gateway-specific boundary algorithm instead of rectangular side snapping: off-axis lanes land on the actual polygon boundary, gateway target slots are derived from polygon-face intersections instead of rectangular side slots, gateway faces use only `1` or `2` centered face slots, short 45-degree diagonal stubs are allowed only on gateway side faces, corner-vertex diagonals are rejected, gateway-target arrival repair now also forbids tiny orthogonal last-moment hooks that change direction within less than one node depth of the boundary, and any retained 45-degree segment longer than one average node-shape length is rejected during scoring and artifact verification. Gateway-source dominant-axis detour checks are opportunity-gated, so obstacle-blocked gateway exits may keep a short local dogleg when there is no clean downstream-facing repair, while the artifact tests still enforce blocker clearance and keep those local exits out of unrelated node clearance bands. Repeat-collector lanes that preserve an outer corridor can still locally reroute their pre-corridor prefix when that prefix crosses a node, so node-safety no longer depends on skipping the edge outright. The document-processing artifact test emits both a live progress log and per-attempt phase timings/route-pass counts alongside the SVG/PNG/JSON diagnostics so long-running strategy searches can be inspected while they are still running and profiled after completion. `Draft` and `Balanced` keep the base route unless library callers opt in through ElkSharp layout options. | +| **ElkSharp** | Port of Eclipse Layout Kernel (default). In `Best` effort mode it runs a deterministic iterative multi-strategy orthogonal router after base routing, scoring candidate layouts across crossings, proximity, labels, target-approach joins, detours, target-approach backtracking, and entry geometry before selecting the best valid result. Attempt 1 remains the only full-strategy reroute; later attempts repair only the currently penalized lanes or exact conflict peers, with shortest-path detours prioritized first, a direct orthogonal shortcut tried before broader rerouting, and corridor-like overshoots only eligible when a clean orthogonal shortcut actually exists. Local-repair candidate building may run in parallel inside an attempt, but builds that touch the same source/target neighborhood are lock-serialized and the final apply order remains deterministic. Small or protected graphs keep the baseline route to preserve established sink-corridor, backward-edge, and port-anchor contracts, while larger congested graphs use the iterative sweep. Final strategy acceptance re-validates post-processed output so remaining broken short highways and non-applicable target-side approach joins are retried instead of being selected, while other soft-rule regressions get bounded multi-attempt retries and a wider but finite strategy sweep before fallback selection. The current A* pathfinder precomputes node-obstacle blocked step masks per route and uses lighter soft-obstacle rejection checks before exact geometry tests, materially reducing route-all-edges time without changing selected-path semantics. A final cheap geometry-repair pass cleans node-side entry/exit angles, target-slot spacing, repeat-collector return lanes, and target-side backtracking without re-running whole-graph A*. Rectangular boundary joins are constrained to a discrete slot lattice so one edge cannot silently concentrate on top of another: `left`/`right` faces may use at most `3` evenly spread side slots, `top`/`bottom` faces may use at most `5`, and the realized slot span matches the same safe boundary inset used by rectangle entry/exit normalization. Gateway faces are limited to `1` centered slot or `2` centered slots, singleton entries and preserved repeat/corridor exits are scored against the same centered lattice instead of being exempt, and the final slot snap can relax the generic shared-lane validator when the centered repair is still obstacle-safe and boundary-valid. Winner refinement now ends with a boundary-slot restabilization pass as well, so late shared-lane or under-node cleanup cannot drift decision/branch source exits back off the assigned lattice. Shortest-path local repair now also reuses interior axes from the current path and tries a raw-clearance obstacle-skirt fallback before accepting a wider preserved overshoot, which lets detour cleanup collapse onto an honest existing lane when the expanded-clearance candidates stay unnecessarily high. Decision/Fork/Join gateway nodes use a gateway-specific boundary algorithm instead of rectangular side snapping: off-axis lanes land on the actual polygon boundary, gateway target slots are derived from polygon-face intersections instead of rectangular side slots, gateway faces use only `1` or `2` centered face slots, short 45-degree diagonal stubs are allowed only on gateway side faces, corner-vertex diagonals are rejected, gateway-target arrival repair now also forbids tiny orthogonal last-moment hooks that change direction within less than one node depth of the boundary, and any retained 45-degree segment longer than one average node-shape length is rejected during scoring and artifact verification. Gateway-source dominant-axis detour checks are opportunity-gated, so obstacle-blocked gateway exits may keep a short local dogleg when there is no clean downstream-facing repair, while the artifact tests still enforce blocker clearance and keep those local exits out of unrelated node clearance bands. Repeat-collector lanes that preserve an outer corridor can still locally reroute their pre-corridor prefix when that prefix crosses a node, so node-safety no longer depends on skipping the edge outright. ElkSharp also supports strict compound-node trees through `ParentNodeId`: leaf nodes and empty parents receive layered positions, non-empty parent rectangles are derived bottom-up from descendant bounds plus `CompoundPadding` and `CompoundHeaderHeight`, exported child coordinates remain absolute, and cross-compound edges now include explicit parent-boundary crossing points. In v1, real edges must still terminate on leaves; non-leaf compound parents remain grouping-only containers and may not declare explicit ports. The document-processing artifact test emits both a live progress log and per-attempt phase timings/route-pass counts alongside the SVG/PNG/JSON diagnostics so long-running strategy searches can be inspected while they are still running and profiled after completion. `Draft` and `Balanced` keep the base route unless library callers opt in through ElkSharp layout options. | | **ElkJS** | JavaScript-based ELK via Node.js | | **MSAGL** | Microsoft Automatic Graph Layout | diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpCompoundLayoutTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpCompoundLayoutTests.cs new file mode 100644 index 000000000..7c65dd52b --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpCompoundLayoutTests.cs @@ -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() + .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 BuildPath(ElkRoutedEdge edge) + { + var points = new List(); + 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 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; +} diff --git a/src/__Libraries/StellaOps.ElkSharp/AGENTS.md b/src/__Libraries/StellaOps.ElkSharp/AGENTS.md index 27ef688d0..1aa652f0e 100644 --- a/src/__Libraries/StellaOps.ElkSharp/AGENTS.md +++ b/src/__Libraries/StellaOps.ElkSharp/AGENTS.md @@ -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. diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundHierarchy.cs b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundHierarchy.cs new file mode 100644 index 000000000..30cce9e78 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundHierarchy.cs @@ -0,0 +1,188 @@ +namespace StellaOps.ElkSharp; + +internal sealed class ElkCompoundHierarchy +{ + private const string RootKey = "\u0001__root__"; + + private readonly Dictionary nodesById; + private readonly Dictionary parentByNodeId; + private readonly Dictionary> childrenByParentId; + private readonly Dictionary ancestorsNearestFirstByNodeId; + private readonly Dictionary depthByNodeId; + private readonly Dictionary originalOrderByNodeId; + + private ElkCompoundHierarchy( + Dictionary nodesById, + Dictionary parentByNodeId, + Dictionary> childrenByParentId, + Dictionary ancestorsNearestFirstByNodeId, + Dictionary depthByNodeId, + Dictionary 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 NodeIds => nodesById.Keys; + + internal IEnumerable NonLeafNodeIds => + nodesById.Keys.Where(IsCompoundNode); + + internal static ElkCompoundHierarchy Build(IReadOnlyCollection nodes) + { + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var originalOrderByNodeId = nodes + .Select((node, index) => new KeyValuePair(node.Id, index)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var parentByNodeId = new Dictionary(StringComparer.Ordinal); + var childrenByParentId = new Dictionary>(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(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(StringComparer.Ordinal); + var depthByNodeId = new Dictionary(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 GetChildIds(string? parentNodeId) + { + var key = parentNodeId ?? RootKey; + return childrenByParentId.TryGetValue(key, out var children) + ? children + : []; + } + + internal IReadOnlyList GetAncestorIdsNearestFirst(string nodeId) => + ancestorsNearestFirstByNodeId[nodeId]; + + internal IEnumerable GetNonLeafNodeIdsByDescendingDepth() => + NonLeafNodeIds + .OrderByDescending(GetDepth) + .ThenBy(GetOriginalOrder); + + internal string? GetLowestCommonAncestor(string leftNodeId, string rightNodeId) + { + var leftAncestors = new HashSet(GetAncestorIdsNearestFirst(leftNodeId), StringComparer.Ordinal); + foreach (var ancestorNodeId in GetAncestorIdsNearestFirst(rightNodeId)) + { + if (leftAncestors.Contains(ancestorNodeId)) + { + return ancestorNodeId; + } + } + + return null; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs new file mode 100644 index 000000000..0946797a1 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkCompoundLayout.cs @@ -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(), StringComparer.Ordinal); + var incomingNodeIds = visibleNodes.ToDictionary(node => node.Id, _ => new List(), StringComparer.Ordinal); + var outgoingNodeIds = visibleNodes.ToDictionary(node => node.Id, _ => new List(), 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(), StringComparer.Ordinal); + var augmentedOutgoing = allNodes.ToDictionary(node => node.Id, _ => new List(), 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(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> incomingNodeIds, + IReadOnlyDictionary> outgoingNodeIds, + IReadOnlyDictionary inputOrder, + int iterationCount, + ElkCompoundHierarchy hierarchy, + IReadOnlySet 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> layers, + int layerIndex, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary inputOrder, + ElkCompoundHierarchy hierarchy, + IReadOnlySet 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 OrderNodesForSubtree( + string? parentNodeId, + ElkCompoundHierarchy hierarchy, + IReadOnlySet realNodeIdsInLayer, + IReadOnlyDictionary> adjacentNodeIds, + IReadOnlyDictionary positions, + IReadOnlyDictionary inputOrder) + { + var blocks = new List(); + 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 nodeIds) + { + var ranks = new List(); + 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 routedEdges, + IReadOnlyDictionary 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 ExtractPath(ElkRoutedEdge edge) + { + var path = new List(); + 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 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 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 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 NormalizePath(IReadOnlyList path) + { + var normalized = new List(path.Count); + foreach (var point in path) + { + AppendPoint(normalized, point); + } + + return normalized; + } + + private static void AppendPoint(ICollection 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 BuildCompoundPositionedNodes( + IReadOnlyCollection graphNodes, + ElkCompoundHierarchy hierarchy, + IReadOnlyDictionary positionedVisibleNodes, + ElkLayoutOptions options) + { + var nodesById = graphNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var compoundNodes = new Dictionary(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 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 ShiftNodes( + IReadOnlyCollection sourceNodes, + IReadOnlyDictionary 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 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 NodeIds, + double Rank, + int MinCurrentPosition, + int MinInputOrder); +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs b/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs index 1c7df27ef..eec9c7997 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkGraphValidator.cs @@ -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 nodes) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs index 8c79205f4..0b1b8a393 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkModels.cs @@ -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; } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index 2d66bedfb..59e046eaf 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -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);