diff --git a/docs/design/rendering-layout/engine/interconnection-layout-engine.md b/docs/design/rendering-layout/engine/interconnection-layout-engine.md index 3d61a45..a6d7e31 100644 --- a/docs/design/rendering-layout/engine/interconnection-layout-engine.md +++ b/docs/design/rendering-layout/engine/interconnection-layout-engine.md @@ -6,10 +6,11 @@ Part of the Rendering Layout system. `InterconnectionLayoutEngine` places directed graphs and routes all connector lines using a full Sugiyama-style pipeline. It is a thin facade that assembles and runs the reusable -`LayeredLayoutPipeline` (see *Layered Pipeline*) with its default stage sequence, the Right layout -direction, and flat hierarchy handling. Its `Place` API and `LayerResult` output are the stable -internal contract; the facade produces byte-for-byte identical geometry to the previous monolithic -implementation, proven by an equivalence test against a legacy oracle. +`LayeredLayoutPipeline` (see *Layered Pipeline*) with its default stage sequence, the requested layout +direction (defaulting to Right), and flat hierarchy handling. Its `Place` API and `LayerResult` output +are the stable internal contract; for the default Right direction the facade produces byte-for-byte +identical geometry to the previous monolithic implementation, proven by an equivalence test against a +legacy oracle. ## InterconnectionLayoutEngine Data Model @@ -22,13 +23,21 @@ with `ConnectorWaypoints`. ## InterconnectionLayoutEngine Methods -`Place(nodes, edges)` builds a `LayeredGraph` from the inputs, assembles a `LayeredLayoutPipeline` -with the default stages, and runs it. It then reads the placed coordinates, column extents, layer -assignments, and waypoints from the graph state and assembles the `LayerResult`. Because the -pipeline drops self-loops, de-duplicates identical directed pairs, and reverses back edges, -`ConnectorWaypoints` holds one polyline per acyclic edge; consumers key a `(source, target)` lookup -on `AcyclicEdges` (reversing the polyline for a reversed back edge) to recover each input edge's -route. +`Place(nodes, edges, direction)` builds a `LayeredGraph` from the inputs, assembles a +`LayeredLayoutPipeline` with the default stages for the requested `direction` (defaulting to Right), +and runs it. It then reads the placed coordinates, column extents, layer assignments, and waypoints +from the graph state and assembles the `LayerResult`. Because the pipeline drops self-loops, +de-duplicates identical directed pairs, and reverses back edges, `ConnectorWaypoints` holds one +polyline per acyclic edge; consumers key a `(source, target)` lookup on `AcyclicEdges` (reversing the +polyline for a reversed back edge) to recover each input edge's route. + +The reported total dimensions are direction-aware. The pipeline's axis-transform stage places the +nodes along the requested direction, so a top-to-bottom (Down) or bottom-to-top (Up) flow transposes +the layout relative to the left-to-right (Right) and right-to-left (Left) flows. The engine computes +the along-axis extent from the column geometry and the cross-axis extent from the placed screen +coordinates, then assigns them to `TotalWidth`/`TotalHeight` per direction: for Right/Left the +along-axis is the width and for Down/Up it is the height. The Right path is unchanged and remains +byte-identical. ## InterconnectionLayoutEngine Error Handling @@ -51,4 +60,5 @@ strategy to obtain a placement result. | Rendering-Layout-InterconnectionEngine-NonOverlapping | InterconnectionLayoutEngine behavior described above | | Rendering-Layout-InterconnectionEngine-DummyNodes | InterconnectionLayoutEngine behavior described above | | Rendering-Layout-InterconnectionEngine-Waypoints | InterconnectionLayoutEngine behavior described above | +| Rendering-Layout-InterconnectionEngine-Direction | InterconnectionLayoutEngine behavior described above | | Rendering-Layout-InterconnectionEngine-Deterministic | InterconnectionLayoutEngine behavior described above | diff --git a/docs/design/rendering-layout/layered-layout-algorithm.md b/docs/design/rendering-layout/layered-layout-algorithm.md index f654396..08caa92 100644 --- a/docs/design/rendering-layout/layered-layout-algorithm.md +++ b/docs/design/rendering-layout/layered-layout-algorithm.md @@ -25,10 +25,17 @@ per node followed by `LayoutLine` per edge). `LayerNode` array from node sizes. 2. **Edge mapping.** Maps each edge to an index pair, dropping any edge that references a node outside this graph. -3. **Placement.** Calls `InterconnectionLayoutEngine.Place` to obtain the `LayerResult`. -4. **Box emission.** Emits one `LayoutBox` per input node, in input order, at the placed rectangle, +3. **Direction resolution.** Resolves the requested flow direction from `CoreOptions.Direction`, + taking an explicit value on the graph in preference to one on the options and falling back to the + property default (`Right`), then maps the public `LayoutFlowDirection` to the engine's internal + direction. This mirrors how the algorithm resolves its other well-known options. +4. **Placement.** Calls `InterconnectionLayoutEngine.Place` with the resolved direction to obtain the + `LayerResult`. For a downward or upward flow the engine transposes the layout so the layers + progress top-to-bottom (or bottom-to-top); `Right` is the default and is byte-identical to the + original left-to-right placement. +5. **Box emission.** Emits one `LayoutBox` per input node, in input order, at the placed rectangle, carrying the node label. -5. **Route resolution.** Builds a `(source, target)` to polyline lookup from the engine's acyclic +6. **Route resolution.** Builds a `(source, target)` to polyline lookup from the engine's acyclic edge set, then emits one `LayoutLine` per input edge. `ResolveRoute` returns the forward polyline when present, reverses the polyline of a reversed back edge, and otherwise falls back to a straight segment between the two node centers (for a self-loop or duplicate edge the engine @@ -50,4 +57,5 @@ the Engine subsystem. It is the entry point resolved by renderers through the la | Rendering-Layout-LayeredAlgorithm-Identity | LayeredLayoutAlgorithm behavior described above | | Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes | LayeredLayoutAlgorithm behavior described above | | Rendering-Layout-LayeredAlgorithm-EmptyGraph | LayeredLayoutAlgorithm behavior described above | +| Rendering-Layout-LayeredAlgorithm-Direction | LayeredLayoutAlgorithm behavior described above | | Rendering-Layout-LayeredAlgorithm-Validation | LayeredLayoutAlgorithm behavior described above | diff --git a/docs/gallery/direction-down.svg b/docs/gallery/direction-down.svg new file mode 100644 index 0000000..3a27b4b --- /dev/null +++ b/docs/gallery/direction-down.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Start + + Collect + + Analyze + + Report + + Archive + + + + + diff --git a/docs/gallery/direction-right.svg b/docs/gallery/direction-right.svg new file mode 100644 index 0000000..6ce0d03 --- /dev/null +++ b/docs/gallery/direction-right.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Start + + Collect + + Analyze + + Report + + Archive + + + + + diff --git a/docs/gallery/gallery.md b/docs/gallery/gallery.md index 92faf68..febcc52 100644 --- a/docs/gallery/gallery.md +++ b/docs/gallery/gallery.md @@ -22,6 +22,20 @@ Sibling boxes packed compactly by the containment algorithm. A container node holding a nested child graph, with a cross-container edge. +## Flow direction + +The same directed graph laid out in two flow directions, selected with the direction option. A rightward flow arranges +the layers left-to-right for block and pipeline diagrams; a downward flow arranges them top-to-bottom for action flows +and state machines, swapping each node's width and height so layer spacing follows node height. + +![Directed flow laid out left to right](direction-right.svg) + +The default rightward direction: layers progress left-to-right. + +![The same directed flow laid out top to bottom](direction-down.svg) + +The downward direction: the same graph's layers progress top-to-bottom. + ## Edge routing Orthogonal connectors step around the boxes between their endpoints instead of cutting through them. diff --git a/docs/reqstream/rendering-layout/engine/engine.yaml b/docs/reqstream/rendering-layout/engine/engine.yaml index b0ae6c8..49b4651 100644 --- a/docs/reqstream/rendering-layout/engine/engine.yaml +++ b/docs/reqstream/rendering-layout/engine/engine.yaml @@ -59,6 +59,7 @@ sections: - Rendering-Layout-InterconnectionEngine-NonOverlapping - Rendering-Layout-InterconnectionEngine-DummyNodes - Rendering-Layout-InterconnectionEngine-Waypoints + - Rendering-Layout-InterconnectionEngine-Direction - Rendering-Layout-InterconnectionEngine-Deterministic tests: - Place_LinearChain_MonotonicLayerAssignment diff --git a/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml b/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml index b9afd55..aa6f13e 100644 --- a/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml +++ b/docs/reqstream/rendering-layout/engine/interconnection-layout-engine.yaml @@ -52,6 +52,18 @@ sections: - Place_SingleEdge_ProducesStraightTwoWaypointPath - Place_LongEdge_RoutesViaDummyNodesWithinBounds + - id: Rendering-Layout-InterconnectionEngine-Direction + title: >- + InterconnectionLayoutEngine shall place the nodes along the requested flow direction, + transposing the layout and its reported total dimensions for a top-to-bottom or bottom-to-top + flow, and shall default to the left-to-right flow. + justification: | + Directed views request a top-to-bottom placement; the engine must honor the requested + direction and report total dimensions that match the transposed layout, while the default + leaves the established left-to-right geometry unchanged. + tests: + - Place_DownDirection_TransposesTotalsRelativeToRight + - id: Rendering-Layout-InterconnectionEngine-Deterministic title: >- Given identical input, InterconnectionLayoutEngine shall produce identical geometry and diff --git a/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml b/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml index 64869b2..5d5d360 100644 --- a/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml +++ b/docs/reqstream/rendering-layout/layered-layout-algorithm.yaml @@ -38,6 +38,21 @@ sections: tests: - Apply_EmptyGraph_ReturnsEmptyCanvas + - id: Rendering-Layout-LayeredAlgorithm-Direction + title: >- + LayeredLayoutAlgorithm shall arrange the placed layers along the flow direction selected by + the direction option — top-to-bottom, bottom-to-top, right-to-left, or the default + left-to-right — reading the selection from the graph in preference to the options. + justification: | + Directed views such as action flows and state machines read the placed coordinates expecting + the layers to progress top-to-bottom; the layout is only correct for them when the algorithm + honors the requested flow direction rather than always flowing left-to-right. + tests: + - Apply_DownDirection_FlowsTopToBottom + - Apply_DownDirection_DiffersFromRight + - Apply_DownDirectionOnGraphScope_IsHonored + - Apply_DefaultDirection_FlowsLeftToRight + - id: Rendering-Layout-LayeredAlgorithm-Validation title: >- LayeredLayoutAlgorithm shall reject a null graph or null options argument with an diff --git a/docs/reqstream/rendering-layout/rendering-layout.yaml b/docs/reqstream/rendering-layout/rendering-layout.yaml index 5862365..ddd9f3d 100644 --- a/docs/reqstream/rendering-layout/rendering-layout.yaml +++ b/docs/reqstream/rendering-layout/rendering-layout.yaml @@ -30,6 +30,7 @@ sections: - Rendering-Layout-LayeredAlgorithm-Identity - Rendering-Layout-LayeredAlgorithm-PlacesAndRoutes - Rendering-Layout-LayeredAlgorithm-EmptyGraph + - Rendering-Layout-LayeredAlgorithm-Direction - Rendering-Layout-LayeredAlgorithm-Validation tests: - Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 8c56c13..90a0313 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -180,7 +180,7 @@ on it: ```csharp var options = new LayoutOptions(); options.Set(CoreOptions.Algorithm, "layered"); -options.Set(CoreOptions.Direction, LayoutFlowDirection.Right); +options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); // flow the layers top-to-bottom // Per-element overrides: any graph element is also a property holder. a.Set(CoreOptions.NodeSpacing, 32.0); @@ -189,6 +189,12 @@ a.Set(CoreOptions.NodeSpacing, 32.0); options.Set(CoreOptions.EdgeRouting, EdgeRouting.Orthogonal); ``` +`CoreOptions.Direction` selects the flow direction the layered algorithm arranges its layers along: +`Right` (the default) and `Left` flow the layers left-to-right and right-to-left, while `Down` and +`Up` flow them top-to-bottom and bottom-to-top — the convention for action flows and state machines. A +downward flow swaps each node's width and height before layering so layer spacing follows node height. +As with the algorithm, a declaration on the graph takes precedence over one on the options. + ## Routing connectors among placed boxes When you have already positioned some boxes yourself — for example a free-form or containment layout diff --git a/docs/verification/rendering-layout/engine/interconnection-layout-engine.md b/docs/verification/rendering-layout/engine/interconnection-layout-engine.md index 638a73d..30b5316 100644 --- a/docs/verification/rendering-layout/engine/interconnection-layout-engine.md +++ b/docs/verification/rendering-layout/engine/interconnection-layout-engine.md @@ -19,6 +19,10 @@ This document maps the InterconnectionLayoutEngine unit requirements to named te - **Waypoints** (`Rendering-Layout-InterconnectionEngine-Waypoints`): `Place_SingleEdge_ProducesStraightTwoWaypointPath` confirms a span-one edge produces a straight two-waypoint path; `Place_LongEdge_RoutesViaDummyNodesWithinBounds` confirms a long edge's route. +- **Direction** (`Rendering-Layout-InterconnectionEngine-Direction`): + `Place_DownDirection_TransposesTotalsRelativeToRight` confirms a downward flow transposes the layout + relative to the default rightward flow — the same chain becomes taller than it is wide with its boxes + stacked in increasing Y — exercising the direction-aware total-dimension computation. - **Deterministic** (`Rendering-Layout-InterconnectionEngine-Deterministic`): the layering scenarios `Place_LinearChain_MonotonicLayerAssignment` and `Place_WorkstationTopology_CorrectLayersAndNoOverlap` assert fixed, reproducible geometry for fixed @@ -34,5 +38,7 @@ This document maps the InterconnectionLayoutEngine unit requirements to named te Place_LongEdge_RectCountEqualsInputNodeCount, Place_LongEdge_RoutesViaDummyNodesWithinBounds - **`Rendering-Layout-InterconnectionEngine-Waypoints`**: Place_SingleEdge_ProducesStraightTwoWaypointPath, Place_LongEdge_RoutesViaDummyNodesWithinBounds +- **`Rendering-Layout-InterconnectionEngine-Direction`**: + Place_DownDirection_TransposesTotalsRelativeToRight - **`Rendering-Layout-InterconnectionEngine-Deterministic`**: Place_LinearChain_MonotonicLayerAssignment, Place_WorkstationTopology_CorrectLayersAndNoOverlap diff --git a/docs/verification/rendering-layout/layered-layout-algorithm.md b/docs/verification/rendering-layout/layered-layout-algorithm.md index 86d94eb..c70ed44 100644 --- a/docs/verification/rendering-layout/layered-layout-algorithm.md +++ b/docs/verification/rendering-layout/layered-layout-algorithm.md @@ -11,6 +11,13 @@ This document maps the layered-layout-algorithm unit requirements to named test connector per edge for a chain graph. - **Empty graph** (`Rendering-Layout-LayeredAlgorithm-EmptyGraph`): `Apply_EmptyGraph_ReturnsEmptyCanvas` confirms an empty graph yields an empty placed layout tree. +- **Direction** (`Rendering-Layout-LayeredAlgorithm-Direction`): + `Apply_DownDirection_FlowsTopToBottom` confirms a downward flow stacks the chain in strictly + increasing Y on a taller-than-wide canvas; `Apply_DownDirection_DiffersFromRight` guards against the + option being ignored by confirming the downward layout is genuinely different from the rightward one; + `Apply_DownDirectionOnGraphScope_IsHonored` confirms the direction is honored when carried on the + graph scope; and `Apply_DefaultDirection_FlowsLeftToRight` confirms the unset default still flows + left-to-right so existing callers are unaffected. - **Validation** (`Rendering-Layout-LayeredAlgorithm-Validation`): `Apply_NullGraph_Throws` confirms a null graph argument is rejected with an argument-null error, and `Apply_NullOptions_Throws` confirms a null options argument is likewise rejected with an argument-null error. @@ -23,5 +30,8 @@ This document maps the layered-layout-algorithm unit requirements to named test Apply_ChainGraph_PlacesLayeredBoxesAndRoutesEdges - **`Rendering-Layout-LayeredAlgorithm-EmptyGraph`**: Apply_EmptyGraph_ReturnsEmptyCanvas +- **`Rendering-Layout-LayeredAlgorithm-Direction`**: + Apply_DownDirection_FlowsTopToBottom, Apply_DownDirection_DiffersFromRight, + Apply_DownDirectionOnGraphScope_IsHonored, Apply_DefaultDirection_FlowsLeftToRight - **`Rendering-Layout-LayeredAlgorithm-Validation`**: Apply_NullGraph_Throws, Apply_NullOptions_Throws diff --git a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs index 07e16d9..5cbc92c 100644 --- a/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs +++ b/src/DemaConsulting.Rendering.Layout/Engine/InterconnectionLayoutEngine.cs @@ -70,10 +70,16 @@ internal static class InterconnectionLayoutEngine /// /// Input nodes to place, in caller order. /// Directed edges between nodes (by index). + /// + /// The primary flow direction the layers progress along. Defaults to + /// , which is byte-identical to the original engine; the other + /// directions are realized by the pipeline's stage. + /// /// Placement result with rects, layer assignments, and connector waypoints. public static LayerResult Place( IReadOnlyList nodes, - IReadOnlyList edges) + IReadOnlyList edges, + LayoutDirection direction = LayoutDirection.Right) { ArgumentNullException.ThrowIfNull(nodes); ArgumentNullException.ThrowIfNull(edges); @@ -84,9 +90,9 @@ public static LayerResult Place( return new LayerResult([], 2.0 * Padding, 2.0 * Padding, [], []); } - var graph = new LayeredGraph(nodes, edges, LayoutDirection.Right); + var graph = new LayeredGraph(nodes, edges, direction); var pipeline = LayeredLayoutPipeline.Builder() - .Direction(LayoutDirection.Right) + .Direction(direction) .Hierarchy(Layered.HierarchyHandling.Flat) .AddDefaultStages() .Build(); @@ -97,21 +103,35 @@ public static LayerResult Place( var columnX = graph.ColumnX; var maxColWidth = graph.MaxColWidth; - // Assemble result. + // Assemble result. After the pipeline's AxisTransform stage the augmented coordinates are in + // screen space, so each box is placed at its screen top-left with its intrinsic (un-swapped) + // width and height. var rects = new Rect[n]; for (var i = 0; i < n; i++) { rects[i] = new Rect(augX[i], augY[i], nodes[i].Width, nodes[i].Height); } + // The abstract stages compute two extents: the along-extent (layer progression, from the + // column geometry) and the cross-extent (within-layer, from the placed screen coordinates). A + // RIGHT/LEFT flow maps the along-axis to screen X and the cross-axis to screen Y; a DOWN/UP flow + // transposes them. Computing both extents once and assigning them by direction keeps the + // RIGHT path byte-identical while giving DOWN/UP correct screen dimensions. var lastLayer = columnX.Length - 1; - var totalWidth = columnX[lastLayer] + maxColWidth[lastLayer] + Padding; - var totalHeight = Padding; + var alongTotal = columnX[lastLayer] + maxColWidth[lastLayer] + Padding; + var transposed = direction is LayoutDirection.Down or LayoutDirection.Up; + var crossTotal = Padding; for (var i = 0; i < n; i++) { - totalHeight = Math.Max(totalHeight, augY[i] + nodes[i].Height + Padding); + var crossFar = transposed + ? augX[i] + nodes[i].Width + : augY[i] + nodes[i].Height; + crossTotal = Math.Max(crossTotal, crossFar + Padding); } + var totalWidth = transposed ? crossTotal : alongTotal; + var totalHeight = transposed ? alongTotal : crossTotal; + return new LayerResult(rects, totalWidth, totalHeight, graph.NodeLayers, graph.Waypoints) { AcyclicEdges = graph.Acyclic, diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index e6eb145..7772579 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -4,6 +4,7 @@ using DemaConsulting.Rendering.Abstractions; using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; namespace DemaConsulting.Rendering.Layout; @@ -11,7 +12,9 @@ namespace DemaConsulting.Rendering.Layout; /// The bundled ELK-style layered layout algorithm: arranges the input graph into Sugiyama layers and /// routes edges orthogonally, producing a placed of boxes and connectors. /// This is the reference implementation; it wraps the reusable layered -/// pipeline under Engine/Layered/. +/// pipeline under Engine/Layered/. It honors so the layers +/// progress right, left, down, or up (a downward flow lays action-flow and state-transition diagrams +/// out top-to-bottom). /// public sealed class LayeredLayoutAlgorithm : ILayoutAlgorithm { @@ -55,7 +58,8 @@ public LayoutTree Apply(LayoutGraph graph, LayoutOptions options) } } - var result = InterconnectionLayoutEngine.Place(engineNodes, engineEdges); + var direction = ToEngineDirection(ResolveDirection(graph, options)); + var result = InterconnectionLayoutEngine.Place(engineNodes, engineEdges, direction); var nodes = new List(count + graph.Edges.Count); @@ -130,4 +134,38 @@ private static IReadOnlyList ResolveRoute( private static Point2D Centre(Rect rect) => new(rect.X + (rect.Width / 2.0), rect.Y + (rect.Height / 2.0)); + + /// + /// Resolves the primary flow direction for this layout: an explicit + /// on the graph takes precedence, then one on the options, + /// falling back to the property default when neither declares one. + /// + /// The graph whose explicit direction declaration takes precedence. + /// The options consulted when the graph declares no direction. + /// The flow direction to lay the graph out along. + private static LayoutFlowDirection ResolveDirection(LayoutGraph graph, LayoutOptions options) + { + if (graph.TryGet(CoreOptions.Direction, out var fromGraph)) + { + return fromGraph; + } + + return options.TryGet(CoreOptions.Direction, out var fromOptions) + ? fromOptions + : CoreOptions.Direction.DefaultValue; + } + + /// + /// Maps the public option to the engine's internal + /// the layered pipeline understands. + /// + /// The public flow direction selected through the options. + /// The equivalent internal engine direction. + private static LayoutDirection ToEngineDirection(LayoutFlowDirection direction) => direction switch + { + LayoutFlowDirection.Down => LayoutDirection.Down, + LayoutFlowDirection.Left => LayoutDirection.Left, + LayoutFlowDirection.Up => LayoutDirection.Up, + _ => LayoutDirection.Right, + }; } diff --git a/src/DemaConsulting.Rendering/Options/CoreOptions.cs b/src/DemaConsulting.Rendering/Options/CoreOptions.cs index 951e1fd..1d02745 100644 --- a/src/DemaConsulting.Rendering/Options/CoreOptions.cs +++ b/src/DemaConsulting.Rendering/Options/CoreOptions.cs @@ -54,9 +54,12 @@ public static class CoreOptions new("rendering.hierarchyhandling", Rendering.HierarchyHandling.SeparateChildren); /// - /// Primary flow direction for layered algorithms. Advisory in the bundled layered - /// algorithm today, which lays out left-to-right; honoring other directions is a future, - /// additive enhancement. + /// Primary flow direction for layered algorithms: the direction successive layers progress in. + /// Honored by the bundled layered algorithm — and + /// flow left-to-right and right-to-left, while + /// and flow + /// top-to-bottom and bottom-to-top (swapping each node's width and height before layering so layer + /// spacing is driven by node height). Defaults to . /// public static readonly LayoutProperty Direction = new("rendering.direction", LayoutFlowDirection.Right); diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index 831ef2b..cc6c58c 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -35,6 +35,8 @@ internal static class GalleryCatalog public const string ContainmentPackedSvg = "containment-packed.svg"; public const string HierarchicalNestedSvg = "hierarchical-nested.svg"; public const string OrthogonalObstacleSvg = "orthogonal-obstacle.svg"; + public const string DirectionRightSvg = "direction-right.svg"; + public const string DirectionDownSvg = "direction-down.svg"; public const string ThemeLightPng = "theme-light.png"; public const string ThemeDarkPng = "theme-dark.png"; public const string ThemePrintPng = "theme-print.png"; @@ -63,6 +65,22 @@ internal static class GalleryCatalog "Hierarchical nested diagram", "A container node holding a nested child graph, with a cross-container edge."), ]), + new GallerySection( + "Flow direction", + "The same directed graph laid out in two flow directions, selected with the direction " + + "option. A rightward flow arranges the layers left-to-right for block and pipeline " + + "diagrams; a downward flow arranges them top-to-bottom for action flows and state " + + "machines, swapping each node's width and height so layer spacing follows node height.", + [ + new GalleryImage( + DirectionRightSvg, + "Directed flow laid out left to right", + "The default rightward direction: layers progress left-to-right."), + new GalleryImage( + DirectionDownSvg, + "The same directed flow laid out top to bottom", + "The downward direction: the same graph's layers progress top-to-bottom."), + ]), new GallerySection( "Edge routing", "Orthogonal connectors step around the boxes between their endpoints instead of cutting " diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index 9756006..fec7865 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -146,6 +146,29 @@ public static LayoutGraph ThemeShowcase() return graph; } + /// + /// A compact directed flow — a start, two sequential steps, a branch, and an end — used to show + /// the same graph laid out in two flow directions. Read left-to-right it is a pipeline; read + /// top-to-bottom it is an action flow, which is exactly what the direction option selects. + /// + /// A flat directed graph suited to a direction comparison. + public static LayoutGraph DirectionShowcase() + { + var graph = new LayoutGraph(); + var start = AddLabelled(graph, "start", "Start"); + var collect = AddLabelled(graph, "collect", "Collect"); + var analyze = AddLabelled(graph, "analyze", "Analyze"); + var report = AddLabelled(graph, "report", "Report"); + var archive = AddLabelled(graph, "archive", "Archive"); + + Connect(graph, "start-collect", start, collect); + Connect(graph, "collect-analyze", collect, analyze); + Connect(graph, "analyze-report", analyze, report); + Connect(graph, "analyze-archive", analyze, archive); + + return graph; + } + /// Adds a labelled leaf node of the standard showcase size to the given graph. private static LayoutGraphNode AddLabelled(LayoutGraph graph, string id, string label) { diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index b07c39b..6ec4d1d 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -71,6 +71,40 @@ public void Gallery_OrthogonalObstacle_RendersSvg() Themes.Dark); } + /// + /// Renders the direction showcase left-to-right, proving the layered algorithm honors the + /// default rightward flow direction. + /// + [Fact] + public void Gallery_DirectionRight_RendersSvg() + { + var options = LayoutOptions.ForAlgorithm("layered"); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Right); + + GalleryWriter.Svg( + GalleryCatalog.DirectionRightSvg, + GalleryDiagrams.DirectionShowcase(), + options, + Themes.Dark); + } + + /// + /// Renders the same direction showcase top-to-bottom, proving the layered algorithm honors a + /// downward flow direction and produces a genuinely different, transposed layout. + /// + [Fact] + public void Gallery_DirectionDown_RendersSvg() + { + var options = LayoutOptions.ForAlgorithm("layered"); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + GalleryWriter.Svg( + GalleryCatalog.DirectionDownSvg, + GalleryDiagrams.DirectionShowcase(), + options, + Themes.Dark); + } + /// /// Renders the representative diagram with the light theme to PNG, giving it a solid light /// background. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs index c3f5eb5..6980596 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/Engine/InterconnectionLayoutEngineTests.cs @@ -3,6 +3,7 @@ // using DemaConsulting.Rendering.Layout.Engine; +using DemaConsulting.Rendering.Layout.Engine.Layered; namespace DemaConsulting.Rendering.Layout.Tests.Engine; @@ -164,6 +165,30 @@ public void Place_WorkstationTopology_CorrectLayersAndNoOverlap() } } + /// + /// A downward flow direction transposes the layout: the same chain that lays out wider than it + /// is tall in the default rightward direction lays out taller than it is wide when placed + /// downward, with its boxes stacked in strictly increasing Y. This exercises the direction-aware + /// total-dimension computation. + /// + [Fact] + public void Place_DownDirection_TransposesTotalsRelativeToRight() + { + var nodes = new List { new(80, 40), new(80, 40), new(80, 40) }; + var edges = new List { new(0, 1), new(1, 2) }; + + var right = InterconnectionLayoutEngine.Place(nodes, edges, LayoutDirection.Right); + var down = InterconnectionLayoutEngine.Place(nodes, edges, LayoutDirection.Down); + + // Rightward flow is wider than tall; the downward flow transposes those dimensions. + Assert.True(right.TotalWidth > right.TotalHeight); + Assert.True(down.TotalHeight > down.TotalWidth); + + // The downward flow stacks the chain's boxes in strictly increasing Y. + Assert.True(down.Rects[0].Y < down.Rects[1].Y); + Assert.True(down.Rects[1].Y < down.Rects[2].Y); + } + private static bool Overlaps(Rect a, Rect b) => a.X < b.X + b.Width && b.X < a.X + a.Width && diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index ed9eb26..9531b0d 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -76,6 +76,115 @@ public void Apply_NullGraph_Throws() () => new LayeredLayoutAlgorithm().Apply(null!, new LayoutOptions())); } + /// + /// Proves that a null options argument is rejected. + /// + [Fact] + public void Apply_NullOptions_Throws() + { + Assert.Throws( + () => new LayeredLayoutAlgorithm().Apply(new LayoutGraph(), null!)); + } + + /// + /// Proves that selecting a downward flow direction lays the chain out top-to-bottom: boxes are + /// stacked in strictly increasing Y (rather than the default left-to-right increasing X), and + /// the canvas is taller than it is wide. + /// + [Fact] + public void Apply_DownDirection_FlowsTopToBottom() + { + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + var c = graph.AddNode("c", 80, 40); + graph.AddEdge("e1", a, b); + graph.AddEdge("e2", b, c); + + var options = new LayoutOptions(); + options.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, options); + + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(3, boxes.Count); + + // A downward chain stacks its boxes vertically: strictly increasing Y. + Assert.True(boxes[0].Y < boxes[1].Y); + Assert.True(boxes[1].Y < boxes[2].Y); + + // A three-deep top-to-bottom flow of short boxes is taller than it is wide. + Assert.True(tree.Height > tree.Width); + } + + /// + /// Proves that the downward flow is a genuinely different layout from the default rightward + /// flow — a regression guard against the option being silently ignored (which would return the + /// identical left-to-right coordinates for both directions). + /// + [Fact] + public void Apply_DownDirection_DiffersFromRight() + { + var algorithm = new LayeredLayoutAlgorithm(); + + var right = algorithm.Apply(BuildChain(), new LayoutOptions()); + + var downOptions = new LayoutOptions(); + downOptions.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + var down = algorithm.Apply(BuildChain(), downOptions); + + // Rightward is wide-and-short; downward is tall-and-narrow. They must not be identical. + Assert.True(right.Width > right.Height); + Assert.True(down.Height > down.Width); + + var rightBoxes = right.Nodes.OfType().ToList(); + var downBoxes = down.Nodes.OfType().ToList(); + Assert.True(rightBoxes[0].X < rightBoxes[2].X); + Assert.True(downBoxes[0].Y < downBoxes[2].Y); + } + + /// + /// Proves that the flow direction is honored when carried on the graph scope, mirroring how the + /// algorithm resolves its other well-known options (graph scope takes precedence over options). + /// + [Fact] + public void Apply_DownDirectionOnGraphScope_IsHonored() + { + var graph = BuildChain(); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + Assert.True(tree.Height > tree.Width); + } + + /// + /// Proves that the default (unset) direction lays the graph out left-to-right, so existing + /// callers that never set the option are unaffected. + /// + [Fact] + public void Apply_DefaultDirection_FlowsLeftToRight() + { + var tree = new LayeredLayoutAlgorithm().Apply(BuildChain(), new LayoutOptions()); + + var boxes = tree.Nodes.OfType().ToList(); + Assert.True(boxes[0].X < boxes[1].X); + Assert.True(boxes[1].X < boxes[2].X); + Assert.True(tree.Width > tree.Height); + } + + /// Builds the standard three-node chain graph used by the direction tests. + private static LayoutGraph BuildChain() + { + var graph = new LayoutGraph(); + var a = graph.AddNode("a", 80, 40); + var b = graph.AddNode("b", 80, 40); + var c = graph.AddNode("c", 80, 40); + graph.AddEdge("e1", a, b); + graph.AddEdge("e2", b, c); + return graph; + } + /// /// Proves that the hierarchical input-model capability is behavior-preserving: a flat graph /// whose nodes declare no children lays out exactly as before, because the layered algorithm