From 3d0ba1bcbbe7f61298be9bc07c5b75f7ba04acf5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 16 Jul 2026 18:45:37 -0400 Subject: [PATCH 1/2] Replace hard cutoff with graduated falloff for containment column-count estimate ContainmentLayoutAlgorithm.ComputeContentWidth's column-count-based width candidate was gated by MaxColumnEstimateSizeRatio = 2.0: the candidate was skipped entirely unless widest/narrowest <= 2.0 for both width and height. This cliff is far too strict for realistic box sets - ordinary, differently-named identifiers routinely exceed a 2x width ratio just from label-length variance, disabling the heuristic and collapsing a balanced multi-column grid into one degenerate column (observed with a real 9-box set, widths 168-351px, ratio 2.08 - barely over the cutoff - rendering as one long column instead of packing 2-3 per row). Since the candidate is combined via Math.Max, it can only ever widen the final budget, never narrow it below the widest-box/area-based floor. Given that asymmetry, replace the hard <= 2.0 cutoff with a graduated linear falloff: full weight at or below ColumnEstimateFullWeightSizeRatio (2.0), zero weight at or above ColumnEstimateZeroWeightSizeRatio (6.0), interpolating linearly in between. Over-applying the estimate costs at most some extra whitespace; under-applying it (the old cliff's failure mode) had no bound on how degenerate the resulting single-column layout got. - Renamed MaxColumnEstimateSizeRatio to ColumnEstimateFullWeightSizeRatio and added ColumnEstimateZeroWeightSizeRatio. - Added ComputeColumnEstimateWeight (graduated falloff) and ComputeSizeRatio (zero-safe ratio) helpers, both made internal (alongside ComputeContentWidth) for direct unit testing. - Added regression coverage spanning the old cliff boundary (ratios just below/above 2.0), the new upper falloff bound (6.0), the interpolated midrange, and an end-to-end test reproducing the exact reported 9-box scenario (ratio ~2.08) now packing into multiple rows/columns instead of collapsing into one column. - Updated design doc and doc comments describing the new graduated behavior instead of the old hard threshold. --- .../containment-layout-algorithm.md | 35 +++-- .../ContainmentLayoutAlgorithm.cs | 131 +++++++++++++---- .../ContainmentLayoutAlgorithmTests.cs | 135 ++++++++++++++++++ 3 files changed, 268 insertions(+), 33 deletions(-) diff --git a/docs/design/rendering-layout/containment-layout-algorithm.md b/docs/design/rendering-layout/containment-layout-algorithm.md index 6fdb576..3974917 100644 --- a/docs/design/rendering-layout/containment-layout-algorithm.md +++ b/docs/design/rendering-layout/containment-layout-algorithm.md @@ -17,12 +17,15 @@ changes no existing output and leaves the layered algorithm untouched. The class is stateless and sealed. It exposes the `AlgorithmId` constant (`"containment"`) and returns it from the `Id` property, the stable identifier under which the algorithm is selected and registered. -Two private constants govern the arrangement: `CanvasAspectRatio` (`4/3`) biases the derived content -width toward a landscape block, and `NodeSpacing` (`24.0` logical pixels) is the inter-box gap, sized -wider than the router's approach stub so a connector can pass cleanly between two packed boxes. Its -single behavior is `Apply(LayoutGraph graph, LayoutOptions options)`, which returns a `LayoutTree` -carrying the packed region size and a flat list of `LayoutNode` items (`LayoutBox` per top-level node -followed by `LayoutLine` per routed edge). +Private constants govern the arrangement: `CanvasAspectRatio` (`4/3`) biases the derived content width +toward a landscape block; `NodeSpacing` (`24.0` logical pixels) is the inter-box gap, sized wider than +the router's approach stub so a connector can pass cleanly between two packed boxes; `MinBoxesForColumnEstimate` +(`6`) gates the column-count-based content-width candidate to sets large enough for multi-column packing +to plausibly make sense; and `ColumnEstimateFullWeightSizeRatio` (`2.0`) / `ColumnEstimateZeroWeightSizeRatio` +(`6.0`) bound the graduated falloff that scales that candidate's contribution by how uniform the boxes +are in size (see Methods, below). Its single behavior is `Apply(LayoutGraph graph, LayoutOptions options)`, +which returns a `LayoutTree` carrying the packed region size and a flat list of `LayoutNode` items +(`LayoutBox` per top-level node followed by `LayoutLine` per routed edge). ### ContainmentLayoutAlgorithm Methods @@ -32,9 +35,23 @@ followed by `LayoutLine` per routed edge). origin (carrying the node's width, height, and label), recording each node's positional index. A node's nested `Children` are treated as opaque and are not laid out at this level (that is the recursive hierarchical engine's responsibility, not this flat algorithm's). -2. **Content-width heuristic.** Derives the packer's `MaxContentWidth` from the boxes: the square root - of their total area scaled by `CanvasAspectRatio`, widened to at least the widest box, and floored - to a small positive value so the packer always receives a usable width — even for an empty graph. +2. **Content-width heuristic.** Derives the packer's `MaxContentWidth` from the boxes via + `ComputeContentWidth`: the square root of their total area scaled by `CanvasAspectRatio`, widened to + at least the widest box, widened further to a column-count-based estimate (`columns = ceil(sqrt(n))` + sized from each box's own average width) once there are at least `MinBoxesForColumnEstimate` boxes, + and floored to a small positive value so the packer always receives a usable width — even for an + empty graph. The column-count-based candidate's contribution is scaled by + `ComputeColumnEstimateWeight`, a graduated falloff on the largest-to-smallest box-size ratio (the + greater of the width ratio and the height ratio): full weight at or below + `ColumnEstimateFullWeightSizeRatio` (`2.0`), zero weight at or above + `ColumnEstimateZeroWeightSizeRatio` (`6.0`), and a linear interpolation between those bounds for a + ratio in between — rather than the hard on/off cutoff the candidate originally used. Because the + candidate is combined via `Math.Max` with the other candidates, it can only ever widen the final + budget, never narrow it; the graduated falloff exists because that asymmetry means over-applying the + estimate costs at most some extra whitespace, while under-applying it (the original hard cutoff's + failure mode) has no bound on how degenerate the resulting single-column layout gets — an ordinary + box set whose width variance only marginally exceeds the old cutoff (for example differently-labelled + peer boxes) still receives most of the estimate's benefit instead of losing it outright. 3. **Packing.** Calls `ContainmentLayout.Pack` with the leaf boxes and a `ContainmentOptions` using the derived width, the connector-aware `NodeSpacing` on both axes, and an `EdgeCounts` map (see below), obtaining the packed boxes and the enclosing region size. diff --git a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs index 6ca870a..b041f6c 100644 --- a/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/ContainmentLayoutAlgorithm.cs @@ -100,14 +100,32 @@ public sealed class ContainmentLayoutAlgorithm : LayoutAlgorithmBase private const int MinBoxesForColumnEstimate = 6; /// - /// Maximum allowed ratio between the largest and smallest box width (and separately height) for the - /// column-count-based width candidate to apply. The motivating scenario for that candidate is many - /// boxes that are each individually wide-but-short and roughly uniform in size (for example a row of - /// small labelled tiles); when box sizes vary widely — as with a couple of very differently sized - /// boxes — the average width used by the estimate is not representative of any single box, so the - /// candidate is skipped rather than risk widening the budget for a shape it was not designed for. + /// Largest-to-smallest box-size ratio (width, and separately height) at or below which the + /// column-count-based width candidate (see ) applies at full + /// weight. The motivating scenario for that candidate is many boxes that are each individually + /// wide-but-short and roughly uniform in size (for example a row of small labelled tiles); at or + /// below this ratio, box sizes are considered uniform enough that the averaged width used by the + /// estimate is representative of every box, so the candidate contributes its full computed width. + /// Above this ratio the candidate's weight falls off smoothly toward zero, rather than being cut + /// off abruptly, up to (see + /// ). /// - private const double MaxColumnEstimateSizeRatio = 2.0; + private const double ColumnEstimateFullWeightSizeRatio = 2.0; + + /// + /// Largest-to-smallest box-size ratio (width, and separately height) at or above which the + /// column-count-based width candidate (see ) contributes nothing. + /// The motivating scenario for suppressing the candidate entirely is a genuinely pathological mix — + /// a couple of very differently sized boxes (for example one much taller than the other) among many + /// small ones — where the averaged width is not representative of any single box and legitimately + /// wants to stack in a single column instead of being forced into a wider grid. Ratios between + /// and this value scale the candidate's weight down + /// linearly rather than snapping straight from full weight to none, since ordinary box sets — for + /// example differently-named peer boxes whose label lengths alone routinely push the ratio just over + /// 2 — should still benefit from most of the candidate's widening rather than losing all of it at an + /// arbitrary cliff. + /// + private const double ColumnEstimateZeroWeightSizeRatio = 6.0; /// public override string Id => AlgorithmId; @@ -244,14 +262,18 @@ private static EdgeRouting ResolveEdgeRouting(LayoutGraph graph, LayoutOptions o /// the balanced, roughly-square block of columns the 4:3 landscape bias intends. This candidate /// is strictly additive — combined via Math.Max, so it only ever widens, never narrows, /// the chosen budget. It is only considered when there are at least - /// boxes that are reasonably uniform in width and height - /// (see ): a handful of very differently sized boxes — - /// for example two boxes where one is much taller than the other — legitimately wants to stack - /// in a single column, and neither the box count nor the averaged width used by the estimate is - /// representative of that shape, so the candidate is skipped and the area-based target - /// (widened only to the widest box) governs instead. + /// boxes, and its contribution is scaled by + /// — full weight when box widths and heights are + /// reasonably uniform, falling off smoothly (rather than an abrupt cliff) as they diverge, and + /// zero once they diverge enough that the averaged width is no longer representative of any + /// single box (a few large outlier boxes mixed with many small ones, where the candidate's + /// underlying premise breaks down). Because the risk is asymmetric — applying the estimate too + /// liberally costs at most some extra whitespace, since Math.Max can only widen the + /// result, while suppressing it entirely for an ordinary, moderately-varied box set collapses a + /// balanced grid into a single degenerate column — the falloff favors keeping most of the + /// estimate's contribution well past the point a hard cutoff would have discarded it outright. /// - private static double ComputeContentWidth(IReadOnlyList boxes) + internal static double ComputeContentWidth(IReadOnlyList boxes) { var totalArea = 0.0; var totalWidth = 0.0; @@ -274,20 +296,81 @@ private static double ComputeContentWidth(IReadOnlyList boxes) // A column-count estimate (columns = ceil(sqrt(n))) sized from each box's own average width, // rather than the total area, so a set of many small but wide boxes still wraps into a // balanced grid of columns instead of one narrow column. Only considered when there are enough - // boxes for multi-column packing to plausibly make sense, and when the boxes are reasonably - // uniform in size — otherwise the average width is not representative of any single box, and a - // small handful of very differently sized boxes (for example one much taller than the other) - // should be free to stack in a single column instead. + // boxes for multi-column packing to plausibly make sense; its contribution is then scaled by + // ComputeColumnEstimateWeight rather than gated all-or-nothing, so ordinary size variance (for + // example label-length-driven width differences) does not throw away the whole estimate. var columnBasedWidth = 0.0; - if (boxes.Count >= MinBoxesForColumnEstimate && - widest / minWidth <= MaxColumnEstimateSizeRatio && - maxHeight / minHeight <= MaxColumnEstimateSizeRatio) + if (boxes.Count >= MinBoxesForColumnEstimate) { - var columns = (int)Math.Ceiling(Math.Sqrt(boxes.Count)); - var averageWidth = totalWidth / boxes.Count; - columnBasedWidth = (columns * averageWidth) + ((columns - 1) * NodeSpacing); + var widthRatio = ComputeSizeRatio(widest, minWidth); + var heightRatio = ComputeSizeRatio(maxHeight, minHeight); + var weight = ComputeColumnEstimateWeight(Math.Max(widthRatio, heightRatio)); + if (weight > 0.0) + { + var columns = (int)Math.Ceiling(Math.Sqrt(boxes.Count)); + var averageWidth = totalWidth / boxes.Count; + var rawColumnBasedWidth = (columns * averageWidth) + ((columns - 1) * NodeSpacing); + columnBasedWidth = weight * rawColumnBasedWidth; + } } return Math.Max(Math.Max(Math.Max(widest, target), columnBasedWidth), MinContentWidth); } + + /// + /// Computes the largest-to-smallest ratio of a box dimension (width, or separately height), treating + /// a zero-or-negative smallest value as a special case rather than dividing by it: a + /// of zero alongside a positive is maximally + /// non-uniform (returns , so + /// yields zero weight), while both being zero is treated as perfectly uniform (returns + /// 1.0) rather than the indeterminate 0/0. + /// + /// The largest observed value for the dimension. + /// The smallest observed value for the dimension. + /// The largest-to-smallest ratio, or a substitute value for the degenerate zero cases. + private static double ComputeSizeRatio(double largest, double smallest) + { + if (smallest <= 0.0) + { + return largest <= 0.0 ? 1.0 : double.PositiveInfinity; + } + + return largest / smallest; + } + + /// + /// Scales the column-count-based width candidate's contribution by how uniform the packed boxes are + /// in size, replacing a hard on/off cutoff with a graduated falloff. Returns 1.0 (full weight) + /// at or below , 0.0 (no contribution) at or + /// above , and linearly interpolates between those two + /// bounds for a ratio in between. A single hard cutoff at + /// (the candidate's original behavior) disabled the estimate entirely for box sets only marginally + /// over the threshold — for example ordinary, differently-labelled peer boxes whose width variance + /// alone often exceeds a 2x ratio — collapsing what should be a balanced multi-column grid into a + /// single degenerate column. Because the candidate can only ever widen the final budget (it is + /// combined via Math.Max in ), over-applying it costs at most + /// some extra whitespace, while under-applying it has no bound on how degenerate the resulting + /// layout gets — so the falloff is deliberately generous, keeping substantial weight well past the + /// old cutoff. + /// + /// + /// The largest-to-smallest size ratio to weight, typically the greater of the width ratio and the + /// height ratio so either axis being non-uniform reduces the candidate's contribution. + /// + /// A weight in the closed range [0.0, 1.0] to scale the column-count-based candidate by. + internal static double ComputeColumnEstimateWeight(double sizeRatio) + { + if (sizeRatio <= ColumnEstimateFullWeightSizeRatio) + { + return 1.0; + } + + if (sizeRatio >= ColumnEstimateZeroWeightSizeRatio) + { + return 0.0; + } + + return (ColumnEstimateZeroWeightSizeRatio - sizeRatio) / + (ColumnEstimateZeroWeightSizeRatio - ColumnEstimateFullWeightSizeRatio); + } } diff --git a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs index 8e51c8f..6a17d92 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/ContainmentLayoutAlgorithmTests.cs @@ -376,6 +376,141 @@ static LayoutGraph BuildGraph(int edgeCount) } } + /// + /// Proves returns full + /// weight (1.0) at and below the full-weight ratio, including the boundary value itself. + /// + [Theory] + [InlineData(1.0)] + [InlineData(1.5)] + [InlineData(2.0)] + public void ComputeColumnEstimateWeight_AtOrBelowFullWeightRatio_ReturnsOne(double sizeRatio) + { + // Act / Assert + Assert.Equal(1.0, ContainmentLayoutAlgorithm.ComputeColumnEstimateWeight(sizeRatio)); + } + + /// + /// Proves returns zero + /// weight at and above the zero-weight ratio, including the boundary value itself. + /// + [Theory] + [InlineData(6.0)] + [InlineData(10.0)] + [InlineData(100.0)] + public void ComputeColumnEstimateWeight_AtOrAboveZeroWeightRatio_ReturnsZero(double sizeRatio) + { + // Act / Assert + Assert.Equal(0.0, ContainmentLayoutAlgorithm.ComputeColumnEstimateWeight(sizeRatio)); + } + + /// + /// Proves interpolates + /// linearly between the full-weight and zero-weight ratios, rather than snapping abruptly from + /// one to the other. + /// + [Theory] + [InlineData(3.0, 0.75)] + [InlineData(4.0, 0.5)] + [InlineData(5.0, 0.25)] + public void ComputeColumnEstimateWeight_BetweenBounds_InterpolatesLinearly(double sizeRatio, double expectedWeight) + { + // Act / Assert + Assert.Equal(expectedWeight, ContainmentLayoutAlgorithm.ComputeColumnEstimateWeight(sizeRatio), precision: 9); + } + + /// + /// Regression guard for the reported bug: a ratio just above the old hard cutoff of 2.0 (for + /// example 2.08, matching a real 9-box set with widths 168–351px) previously disabled the + /// column-count-based estimate entirely, collapsing a balanced multi-column grid into a single + /// degenerate column. With the graduated falloff, a ratio this close to the old cutoff still + /// carries substantial weight rather than dropping to zero. + /// + [Fact] + public void ComputeColumnEstimateWeight_JustAboveOldCutoff_StillContributesSubstantialWeight() + { + // Act + var weight = ContainmentLayoutAlgorithm.ComputeColumnEstimateWeight(2.08); + + // Assert: under the old all-or-nothing cutoff this would have been exactly 0.0. + Assert.True(weight > 0.9, $"Expected substantial weight just above the old cutoff, got {weight}."); + } + + /// + /// Proves widens the content budget + /// past the plain area-based/widest-box floor when box sizes are reasonably uniform (full + /// weight), and that the widened result strictly decreases as size variance grows through the + /// graduated falloff range, converging on the un-widened floor once variance reaches the + /// zero-weight ratio — proving the estimate's contribution actually scales with uniformity + /// rather than jumping between two fixed states. + /// + [Fact] + public void ComputeContentWidth_IncreasingSizeVariance_MonotonicallyReducesColumnEstimateContribution() + { + // Arrange: nine boxes - eight fixed "wide" boxes (300px) that set the widest-box floor, plus one + // "narrow" box whose width is dialed down to move the width ratio (widest/narrowest) through the + // full-weight (2.0), partial-weight (4.0), and zero-weight (6.0) boundaries while the widest box + // (and hence the area-based/widest-box floor) stays essentially constant. + static IReadOnlyList BuildBoxes(double narrowWidth) + { + var boxes = new List(); + for (var i = 0; i < 8; i++) + { + boxes.Add(new LayoutBox(0, 0, 300, 40, $"n{i}", 0, BoxShape.Rectangle, [], [])); + } + + boxes.Add(new LayoutBox(0, 0, narrowWidth, 40, "narrow", 0, BoxShape.Rectangle, [], [])); + return boxes; + } + + // Act: compute content width at full weight (ratio 2.0), partial weight (ratio 4.0), and the + // zero-weight boundary (ratio 6.0). + var fullWeightWidth = ContainmentLayoutAlgorithm.ComputeContentWidth(BuildBoxes(150)); + var partialWeightWidth = ContainmentLayoutAlgorithm.ComputeContentWidth(BuildBoxes(75)); + var zeroWeightBoxes = BuildBoxes(50); + var zeroWeightWidth = ContainmentLayoutAlgorithm.ComputeContentWidth(zeroWeightBoxes); + + // Assert: strictly decreasing contribution as variance grows. + Assert.True(fullWeightWidth > partialWeightWidth, "Expected full weight to widen more than partial weight."); + Assert.True(partialWeightWidth > zeroWeightWidth, "Expected partial weight to widen more than zero weight."); + + // At the zero-weight boundary the column estimate contributes nothing, so the result must equal + // the plain widest-box/area-based floor with no column-estimate term at all. + var widest = zeroWeightBoxes.Max(box => box.Width); + var totalArea = zeroWeightBoxes.Sum(box => box.Width * box.Height); + var floor = Math.Max(widest, Math.Sqrt(totalArea * (4.0 / 3.0))); + Assert.Equal(floor, zeroWeightWidth, precision: 6); + } + + /// + /// End-to-end regression test for the reported bug: nine boxes shaped after a real repro (widths + /// spanning 168–351px, a ratio of ~2.08 — barely over the old hard 2.0 cutoff) must still wrap + /// into a balanced multi-column grid rather than collapse into one long column. + /// + [Fact] + public void Apply_NineBoxesJustAboveOldCutoffRatio_PacksIntoMultipleColumns() + { + // Arrange: nine boxes with widths spanning 168-351px (ratio 351/168 ≈ 2.09), uniform height. + var widths = new[] { 168, 351, 210, 240, 190, 300, 220, 260, 200 }; + var graph = new LayoutGraph(); + for (var i = 0; i < widths.Length; i++) + { + graph.AddNode($"n{i}", widths[i], 40).Label = $"N{i}"; + } + + // Act + var tree = new ContainmentLayoutAlgorithm().ApplyCore(graph, new LayoutOptions()); + + // Assert: the boxes span more than one row and more than one column, instead of one long column. + var boxes = tree.Nodes.OfType().ToList(); + Assert.Equal(widths.Length, boxes.Count); + var distinctRows = boxes.Select(box => box.Y).Distinct().Count(); + Assert.True(distinctRows > 1, $"Expected multiple rows, but all {boxes.Count} boxes landed on {distinctRows} row(s)."); + + var distinctColumns = boxes.Select(box => box.X).Distinct().Count(); + Assert.True(distinctColumns > 1, $"Expected multiple columns, but all {boxes.Count} boxes landed on {distinctColumns} column(s)."); + } + /// /// Determines whether two boxes overlap with a positive-area intersection. /// From e3af071056679e8a981806f0ca3fab76ad3ad0eb Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 16 Jul 2026 18:57:33 -0400 Subject: [PATCH 2/2] Regenerate gallery: containment-packed now wraps into a balanced grid The 12 module-name boxes in this diagram vary in width just enough (driven by label length) that the column-count-based content-width candidate previously fell just over the old hard 2.0 size-ratio cutoff and was skipped entirely, collapsing the layout into a single narrow column (332x444). With the graduated falloff fix, the candidate still contributes substantial weight at this ratio, producing the intended balanced multi-column grid (452x296). --- .../containment-packed.svg | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/gallery/connectivity-and-clusters/containment-packed.svg b/docs/gallery/connectivity-and-clusters/containment-packed.svg index 508f811..4724ed4 100644 --- a/docs/gallery/connectivity-and-clusters/containment-packed.svg +++ b/docs/gallery/connectivity-and-clusters/containment-packed.svg @@ -1,4 +1,4 @@ - + @@ -34,24 +34,24 @@ Core Model - - Layout - - Svg - - Skia - - Abstractions - - Themes - - Options - - Engine - - Registry - - Renderer - - Graph + + Layout + + Svg + + Skia + + Abstractions + + Themes + + Options + + Engine + + Registry + + Renderer + + Graph