diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 8bf7ba4..27680f7 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -153,6 +153,11 @@ either side. The companion top/bottom case: two ports per face spread horizontally instead of vertically, proving the same crowding and title-collision protection when PortDistributor works along the cross axis of a downward flow. +![A titled hub node with several unlabeled ports fanning out to four other boxes](ports-showcase-unlabeled-fan-out.svg) + +A face with several ports carrying no label or text at all still grows the box tall enough to keep them from bunching +together — the growth floor applies unconditionally to any face with two or more anchors, not only labeled ones. + ## Boundary and delegation ports The hierarchical engine's support for boundary (delegation) ports: a container may expose a named port carrying BOTH an diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.png b/docs/gallery/boundary-ports-showcase-deep-chain.png index 01235c8..c8801a8 100644 Binary files a/docs/gallery/boundary-ports-showcase-deep-chain.png and b/docs/gallery/boundary-ports-showcase-deep-chain.png differ diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.svg b/docs/gallery/boundary-ports-showcase-deep-chain.svg index c3e0ad0..ea0dd0c 100644 --- a/docs/gallery/boundary-ports-showcase-deep-chain.svg +++ b/docs/gallery/boundary-ports-showcase-deep-chain.svg @@ -42,9 +42,9 @@ - handle - relay + handle + relay - route - request + route + request diff --git a/docs/gallery/boundary-ports-showcase-horizontal.png b/docs/gallery/boundary-ports-showcase-horizontal.png index 22e5432..9430e3a 100644 Binary files a/docs/gallery/boundary-ports-showcase-horizontal.png and b/docs/gallery/boundary-ports-showcase-horizontal.png differ diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/boundary-ports-showcase-horizontal.svg index f5bb09c..08a1620 100644 --- a/docs/gallery/boundary-ports-showcase-horizontal.svg +++ b/docs/gallery/boundary-ports-showcase-horizontal.svg @@ -42,6 +42,6 @@ - dispatch - command + dispatch + command diff --git a/docs/gallery/boundary-ports-showcase-vertical.png b/docs/gallery/boundary-ports-showcase-vertical.png index 3a8c27a..68ada59 100644 Binary files a/docs/gallery/boundary-ports-showcase-vertical.png and b/docs/gallery/boundary-ports-showcase-vertical.png differ diff --git a/docs/gallery/boundary-ports-showcase-vertical.svg b/docs/gallery/boundary-ports-showcase-vertical.svg index beafb3a..efbb0f5 100644 --- a/docs/gallery/boundary-ports-showcase-vertical.svg +++ b/docs/gallery/boundary-ports-showcase-vertical.svg @@ -35,13 +35,13 @@ Operator - Controller + Controller Driver - dispatch - command + dispatch + command diff --git a/docs/gallery/parallel-edges-preserved.png b/docs/gallery/parallel-edges-preserved.png index f5107f4..bd5a955 100644 Binary files a/docs/gallery/parallel-edges-preserved.png and b/docs/gallery/parallel-edges-preserved.png differ diff --git a/docs/gallery/parallel-edges-preserved.svg b/docs/gallery/parallel-edges-preserved.svg index c31fa06..6b5a6e9 100644 --- a/docs/gallery/parallel-edges-preserved.svg +++ b/docs/gallery/parallel-edges-preserved.svg @@ -1,4 +1,4 @@ - + @@ -30,14 +30,14 @@ - - Producer - - Consumer - - - - primary - retry - audit + + Producer + + Consumer + + + + primary + retry + audit diff --git a/docs/gallery/ports-showcase-horizontal.png b/docs/gallery/ports-showcase-horizontal.png index 59ffca5..8f3ce1f 100644 Binary files a/docs/gallery/ports-showcase-horizontal.png and b/docs/gallery/ports-showcase-horizontal.png differ diff --git a/docs/gallery/ports-showcase-multi-connector-horizontal.png b/docs/gallery/ports-showcase-multi-connector-horizontal.png index 8b76a27..658a9d5 100644 Binary files a/docs/gallery/ports-showcase-multi-connector-horizontal.png and b/docs/gallery/ports-showcase-multi-connector-horizontal.png differ diff --git a/docs/gallery/ports-showcase-multi-connector-horizontal.svg b/docs/gallery/ports-showcase-multi-connector-horizontal.svg index d89ac39..bc53377 100644 --- a/docs/gallery/ports-showcase-multi-connector-horizontal.svg +++ b/docs/gallery/ports-showcase-multi-connector-horizontal.svg @@ -34,22 +34,22 @@ SensorA SensorB - - Hub + + Hub ActuatorA ActuatorB - - - a rather long telemetry feed - - - heartbeat - - - primary drive command - - - diag + + + a rather long telemetry feed + + + heartbeat + + + primary drive command + + + diag diff --git a/docs/gallery/ports-showcase-multi-connector-vertical.png b/docs/gallery/ports-showcase-multi-connector-vertical.png index c0ffa7a..db9f272 100644 Binary files a/docs/gallery/ports-showcase-multi-connector-vertical.png and b/docs/gallery/ports-showcase-multi-connector-vertical.png differ diff --git a/docs/gallery/ports-showcase-multi-connector-vertical.svg b/docs/gallery/ports-showcase-multi-connector-vertical.svg index 5d3231b..71001c7 100644 --- a/docs/gallery/ports-showcase-multi-connector-vertical.svg +++ b/docs/gallery/ports-showcase-multi-connector-vertical.svg @@ -1,4 +1,4 @@ - + @@ -34,22 +34,22 @@ MonitorA MonitorB - - Hub - - ControlA - - ControlB + + Hub + + ControlA + + ControlB a rather long status feed alert - - - primary control command - - - backup + + + primary control command + + + backup diff --git a/docs/gallery/ports-showcase-unlabeled-fan-out.png b/docs/gallery/ports-showcase-unlabeled-fan-out.png new file mode 100644 index 0000000..ad69869 Binary files /dev/null and b/docs/gallery/ports-showcase-unlabeled-fan-out.png differ diff --git a/docs/gallery/ports-showcase-unlabeled-fan-out.svg b/docs/gallery/ports-showcase-unlabeled-fan-out.svg new file mode 100644 index 0000000..4f40a73 --- /dev/null +++ b/docs/gallery/ports-showcase-unlabeled-fan-out.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + board : Motherboard + + cpu : Cpu + + memory : Ram + + storage : Ssd + + network : NetworkCard + + graphics : Gpu + + + + + + + + + + diff --git a/docs/gallery/ports-showcase-vertical.png b/docs/gallery/ports-showcase-vertical.png index d9f88f3..6b15e7d 100644 Binary files a/docs/gallery/ports-showcase-vertical.png and b/docs/gallery/ports-showcase-vertical.png differ diff --git a/docs/gallery/ports-showcase-vertical.svg b/docs/gallery/ports-showcase-vertical.svg index d8607c3..a85839b 100644 --- a/docs/gallery/ports-showcase-vertical.svg +++ b/docs/gallery/ports-showcase-vertical.svg @@ -1,4 +1,4 @@ - + @@ -32,14 +32,14 @@ Monitor - - Hub - - Control + + Hub + + Control status - - - ctrl + + + ctrl diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index 2fc9122..7b6ea31 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -363,6 +363,14 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // trigger this floor and could render its stacked port labels crowded/overlapping — the // exact same faceAnchors-vs-sidePorts distinction as hasLabeledLeftOrRightAnchor above. // bySide (from sidePorts) is reused here for the same reason. + // + // Any face with 2+ anchors — labeled or not — still needs enough physical room to keep + // adjacent port glyphs and their outgoing connector routing from visually crowding each + // other; a box is allowed to say "I have enough ports on me that I need to spread them + // out a bit" even when none of those ports carry any text. Only the label-driven terms + // below (labelHeight/labelWidth scaling, and the downward-shift compensation) are gated + // on actual labels being present, since they have no meaning otherwise; the baseline + // per-anchor connector-clearance floor applies unconditionally whenever Total >= 2. if (faceAnchors.TryGetValue(i, out var byFace)) { var labelHeight = ConnectorLabelPlacer.EstimateLabelHeight(assumedFontSize); @@ -371,8 +379,9 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) List? sideLabels = null; bySide?.TryGetValue(side, out sideLabels); var sidePortLabeled = sideLabels != null && sideLabels.Exists(l => !string.IsNullOrEmpty(l)); + var anyLabeled = faceInfo.AnyLabeled || sidePortLabeled; - if (faceInfo.Total < 2 || (!faceInfo.AnyLabeled && !sidePortLabeled)) + if (faceInfo.Total < 2) { continue; } @@ -385,9 +394,36 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // old endpoint-inclusive linear formula gave) — require that per-slice height // to be at least the label's own line height, with no separate outer-edge // buffer term needed since the outermost slice's own half-height already gives - // proportional corner clearance. - var minHeightCandidate = Math.Max(labelHeight * faceInfo.Total, 2.0 * LayeredLayoutMetrics.ConnectorClearance) + // proportional corner clearance. When no label is present, fall back to a + // per-anchor connector-clearance floor so unlabeled ports still get enough + // absolute room to avoid crowding (scaled by Total, unlike the historical flat + // 2 * ConnectorClearance floor, which never bound in practice for the labeled + // case anyway and was too weak on its own for several unlabeled ports). + var labelBasedHeight = anyLabeled ? labelHeight * faceInfo.Total : 0.0; + var clearanceBasedHeight = 2.0 * LayeredLayoutMetrics.ConnectorClearance * faceInfo.Total; + var minHeightCandidate = Math.Max(labelBasedHeight, clearanceBasedHeight) + effectiveTopReserve + insetBottom; + + // Same asymmetric-downward-label-shift compensation as the single-port + // minHeight floor above (SvgRenderer.EmitPortLabel draws a Left/Right port's + // ExternalLabel at CentreY + FontSizeBody/2, not centered on the port row) — + // without it, this multi-port candidate can win the Math.Max below yet still + // leave no slack for the bottom-most labeled port's downward-shifted text, + // which then runs past the box's bottom edge exactly as the single-port case + // would without this term. PortDistributor.DistributePorts centers each of + // faceInfo.Total ports within its own equal-height slice of the added band, so + // a flat addition here is only fully absorbed by the bottom-most slice's own + // height when there is exactly one slice (Total == 1); for Total >= 2 a flat + // addend is instead divided across every slice's height (each slice gains only + // addend / Total), silently eroding the intended margin as Total grows. Scale + // by faceInfo.Total so every slice's height still gains the full + // assumedFontSize / 2 the single-port case relies on, regardless of how many + // ports share the face. + if (sidePortLabeled) + { + minHeightCandidate += assumedFontSize / 2.0 * faceInfo.Total; + } + minHeight = Math.Max(minHeight, minHeightCandidate); } else @@ -410,10 +446,17 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // Same equal-area-slice reasoning as the Left/Right branch above, but along // the width axis: each port's slice must be at least as wide as the widest - // same-side label so a Top/Bottom label (rendered centred on its port) never - // overlaps its neighbor's label or overflows past the box's own edge. + // same-side label so a Top/Bottom label (rendered centered on its port) never + // overlaps its neighbor's label or overflows past the box's own edge. When no + // label is present, fall back to a per-anchor connector-clearance floor (scaled + // by Total, matching the Left/Right branch's clearanceBasedHeight) so unlabeled + // Top/Bottom ports still get enough absolute room to avoid crowding — a flat, + // unscaled floor here would reproduce the same bunching bug the Left/Right + // branch was fixed for, just rotated 90 degrees. var maxLabelWidth = Math.Max(faceInfo.MaxLabelWidth, sidePortMaxLabelWidth); - var minWidthCandidate = Math.Max(maxLabelWidth * faceInfo.Total, 2.0 * LayeredLayoutMetrics.ConnectorClearance); + var labelBasedWidth = anyLabeled ? maxLabelWidth * faceInfo.Total : 0.0; + var clearanceBasedWidth = 2.0 * LayeredLayoutMetrics.ConnectorClearance * faceInfo.Total; + var minWidthCandidate = Math.Max(labelBasedWidth, clearanceBasedWidth); minWidth = Math.Max(minWidth, minWidthCandidate); } } @@ -698,8 +741,14 @@ double SideWidth(PortSide side) // A renderer draws a top/bottom port's label roughly (PortLabelClearance + 1.5 x // assumedFontSize) away from the box edge, regardless of the inset value; the box title // starts immediately after the inset, so the inset itself must be at least that deep (with - // margin) for a box that has both a title and a top/bottom port to avoid overlap. - var titleClearance = (2.0 * assumedFontSize) + (2.0 * PortLabelClearance); + // margin) for a box that has both a title and a top/bottom port to avoid overlap. A + // boundary port (one with both an InternalLabel and an ExternalLabel) additionally offsets + // its InternalLabel by NotationMetrics.EndMarkerLength beyond a plain port's offset (see + // SvgRenderer.EmitPortLabel's remarks), pushing the label that much further toward the + // title; this function only sees the aggregated per-side label text (not whether any of + // it came from a boundary port), so the clearance is unconditionally widened by that same + // amount to safely cover the worst case. + var titleClearance = (2.0 * assumedFontSize) + (2.0 * PortLabelClearance) + NotationMetrics.EndMarkerLength; if (top > 0.0) { top = Math.Max(top, titleClearance); diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs index 7f4c9fe..ac89356 100644 --- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -1298,10 +1298,14 @@ private static void RenderPort(SKCanvas canvas, LayoutPort port, RenderOptions o }; /// - /// Draws one port label offset from the port centre using the interior-side formula for + /// Draws one port label offset from the port center using the interior-side formula for /// (the port's own side for an inward label, the opposite side for an /// outward one), so an inward and an outward label on one boundary port sit symmetrically about the - /// port centre. + /// port center. A boundary port (one carrying both an and an + /// ) reserves extra clearance beyond the plain port-glyph + /// offset — see SvgRenderer.EmitPortLabel's matching remarks for why: its external label is + /// deliberately drawn on the same outward face its external approach edge may terminate with an + /// end-marker glyph, which would otherwise visually collide with the label text. /// private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, PortSide offsetSide, RenderOptions options) { @@ -1310,7 +1314,8 @@ private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, var strokeColor = SKColor.Parse(theme.StrokeColor); // Offset the label far enough from the port square so it does not overlap. - var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding + + (port.InternalLabel != null && port.ExternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); var (labelX, labelY, align) = offsetSide switch { PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, SKTextAlign.Center), @@ -1323,7 +1328,17 @@ private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, using var font = CreateFont((float)theme.FontSizeBody * scale, bold: false, italic: false); var maxLabelWidth = (float)(port.MaxLabelWidth * scale); font.Size = FitFontSize(font, text, maxLabelWidth, font.Size); - canvas.DrawText(text, (float)(labelX * scale), (float)(labelY * scale), align, font, textPaint); + + // labelY is a *center* Y, matching SvgRenderer.EmitPortLabel's dominant-baseline="middle" + // semantics (the same offset formula is shared by both renderers). SKCanvas.DrawText instead + // takes a baseline Y, so drawing at labelY directly would float the glyph body above that + // center (an ascent-heavy font renders mostly above its baseline) instead of straddling it — + // exactly the PNG/SVG divergence visible when comparing a port label against its connecting + // line. Recover the equivalent baseline from the font's own ascent/descent metrics so both + // renderers place the glyph body identically relative to labelY. + var metrics = font.Metrics; + var baselineY = (float)(labelY * scale) - ((metrics.Ascent + metrics.Descent) / 2.0f); + canvas.DrawText(text, (float)(labelX * scale), baselineY, align, font, textPaint); } /// diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index 62800bc..b438256 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -1011,14 +1011,24 @@ private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, d }; /// - /// Emits one port <text> label offset from the port centre toward the interior of the + /// Emits one port <text> label offset from the port center toward the interior of the /// box on (which equals the port's own side for an inward label and the /// opposite side for an outward one), using the same offset formula for both so an inward and an - /// outward label on one boundary port sit symmetrically about the port centre. + /// outward label on one boundary port sit symmetrically about the port center. A boundary port (one + /// carrying both an and an ) + /// reserves extra clearance beyond the plain port-glyph offset: unlike a plain port's single label — + /// always drawn on the side away from wherever its own connecting edge's arrowhead could + /// land — a boundary port's external label is deliberately drawn on the outward face, the + /// same side its external approach edge may terminate with an end-marker glyph + /// ( long). Without this extra clearance the label text + /// and that marker visually collide (see the "command"/"dispatch" gallery regression). Applying the + /// same widened offset to both the internal and external label keeps them symmetric about the port + /// center even though only the external side is ever actually at risk in practice. /// private static void EmitPortLabel(StringBuilder sb, LayoutPort port, string text, PortSide offsetSide, Theme theme, double scale) { - var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding + + (port.InternalLabel != null && port.ExternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); var (labelX, labelY, anchor) = offsetSide switch { PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, TextAnchorMiddle), diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index 866d2ce..29b2ecb 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -53,6 +53,7 @@ internal static class GalleryCatalog public const string PortsShowcaseVerticalSvg = "ports-showcase-vertical.svg"; public const string PortsShowcaseMultiConnectorHorizontalSvg = "ports-showcase-multi-connector-horizontal.svg"; public const string PortsShowcaseMultiConnectorVerticalSvg = "ports-showcase-multi-connector-vertical.svg"; + public const string PortsShowcaseUnlabeledFanOutSvg = "ports-showcase-unlabeled-fan-out.svg"; public const string BoundaryPortsShowcaseHorizontalSvg = "boundary-ports-showcase-horizontal.svg"; public const string BoundaryPortsShowcaseVerticalSvg = "boundary-ports-showcase-vertical.svg"; public const string BoundaryPortsShowcaseDeepChainSvg = "boundary-ports-showcase-deep-chain.svg"; @@ -60,6 +61,7 @@ internal static class GalleryCatalog public const string PortsShowcaseVerticalPng = "ports-showcase-vertical.png"; public const string PortsShowcaseMultiConnectorHorizontalPng = "ports-showcase-multi-connector-horizontal.png"; public const string PortsShowcaseMultiConnectorVerticalPng = "ports-showcase-multi-connector-vertical.png"; + public const string PortsShowcaseUnlabeledFanOutPng = "ports-showcase-unlabeled-fan-out.png"; public const string ParallelEdgesMergedPng = "parallel-edges-merged.png"; public const string ParallelEdgesPreservedPng = "parallel-edges-preserved.png"; public const string ParallelEdgesPreservedVerticalPng = "parallel-edges-preserved-vertical.png"; @@ -241,6 +243,12 @@ internal static class GalleryCatalog "The companion top/bottom case: two ports per face spread horizontally instead of " + "vertically, proving the same crowding and title-collision protection when " + "PortDistributor works along the cross axis of a downward flow."), + new GalleryImage( + PortsShowcaseUnlabeledFanOutSvg, + "A titled hub node with several unlabeled ports fanning out to four other boxes", + "A face with several ports carrying no label or text at all still grows the box " + + "tall enough to keep them from bunching together — the growth floor applies " + + "unconditionally to any face with two or more anchors, not only labeled ones."), ]), new GallerySection( "Boundary and delegation ports", diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index a314162..2f58d41 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -566,6 +566,45 @@ public static LayoutGraph PortsShowcaseMultiConnectorVertical() return graph; } + /// + /// A titled hub node with several unlabeled ports fanning out to unevenly-spaced + /// targets — regression coverage (and visual evidence) for the growth-floor fix ensuring a face + /// with 2+ anchors still reserves adequate per-port clearance even when none of them carry any + /// text at all. Mirrors the externally reported "Motherboard" bug: a box with a pile of plain, + /// unlabeled connectors used to never grow past its caller-supplied size to give them room, + /// visually bunching several connector exit points close together near the face's center. + /// + /// A six-node graph whose central hub has four unlabeled outgoing ports (a fifth node, + /// memory, hangs off cpu rather than the hub directly). + public static LayoutGraph PortsShowcaseUnlabeledFanOut() + { + var graph = new LayoutGraph(); + + var board = graph.AddNode("board", 140, 50); + board.Label = "board : Motherboard"; + + var cpu = AddLabelled(graph, "cpu", "cpu : Cpu"); + var memory = AddLabelled(graph, "memory", "memory : Ram"); + var storage = AddLabelled(graph, "storage", "storage : Ssd"); + var network = AddLabelled(graph, "network", "network : NetworkCard"); + var graphics = AddLabelled(graph, "graphics", "graphics : Gpu"); + + var cpuPort = board.Ports.AddPort("cpu-link"); + Connect(graph, "board-cpu", cpuPort, cpu, null); + Connect(graph, "cpu-memory", cpu, memory, null); + + var storagePort = board.Ports.AddPort("storage-link"); + Connect(graph, "board-storage", storagePort, storage, null); + + var networkPort = board.Ports.AddPort("network-link"); + Connect(graph, "board-network", networkPort, network, null); + + var graphicsPort = board.Ports.AddPort("graphics-link"); + Connect(graph, "board-graphics", graphicsPort, graphics, null); + + return graph; + } + /// /// A sibling node joined to a container's boundary (delegation) port, which in turn /// delegates inward to two nested children — the canonical boundary-port diagram, laid out diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index 7f72c3c..7f65915 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -336,6 +336,20 @@ public void Gallery_PortsShowcaseMultiConnectorVertical_RendersSvg() Themes.Dark); } + /// + /// Renders the unlabeled-port-fan-out showcase to SVG, proving a titled hub node with several + /// unlabeled ports on one face still grows tall enough to spread them apart instead of bunching + /// them together — regression coverage for the reported "Motherboard" clustering bug. + /// + [Fact] + public void Gallery_PortsShowcaseUnlabeledFanOut_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.PortsShowcaseUnlabeledFanOutSvg, + GalleryDiagrams.PortsShowcaseUnlabeledFanOut(), + Themes.Dark); + } + /// /// Renders the horizontal boundary-ports showcase to SVG, proving a container's boundary /// (delegation) port emits one shared anchor carrying both an outward @@ -433,6 +447,19 @@ public void Gallery_PortsShowcaseMultiConnectorVertical_RendersPng() Themes.Dark); } + /// + /// Renders the unlabeled-port-fan-out showcase to PNG, proving the raster path also grows a + /// titled hub with several unlabeled ports tall enough to spread them apart. + /// + [Fact] + public void Gallery_PortsShowcaseUnlabeledFanOut_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.PortsShowcaseUnlabeledFanOutPng, + GalleryDiagrams.PortsShowcaseUnlabeledFanOut(), + Themes.Dark); + } + /// /// Renders the parallel-edges-merged diagram to PNG, proving the raster path handles merged /// parallel connectors. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index 3829560..ef934fe 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -4,6 +4,7 @@ using DemaConsulting.Rendering; using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine.Layered; namespace DemaConsulting.Rendering.Layout.Tests; @@ -857,6 +858,223 @@ public void Apply_NodeWithLongLeftAndRightPortLabels_BothSidesAvoidSqueeze() $"Expected right MaxLabelWidth >= {measuredRightWidth}, was {outLayoutPort.MaxLabelWidth}."); } + /// + /// Proves that a titled node with two labeled ports stacked on its left (or right) face reserves + /// enough bottom clearance for the bottom-most port's downward-shifted label + /// (SvgRenderer.EmitPortLabel and the Skia renderer's equivalent draw a Left/Right port's + /// at CentreY + FontSizeBody / 2, not centered + /// on the port row). Regression coverage for the gallery bug where "Hub"'s bottom-most labeled + /// port ("heartbeat"/"diag") rendered its label past the box's own bottom border: the growth + /// floor's per-port compensation for this downward shift was a flat addend divided across every + /// port's own equal-height slice (PortDistributor.DistributePorts centers each port within its + /// own equal-height slice of the face), so it was fully absorbed only when a face carried + /// exactly one port — a face with 2+ labeled ports lost most of that compensation to the other + /// slices. Asserts the bottom-most port's own margin to the box's bottom edge covers both the + /// downward shift itself and the shifted label's own half-height (2 * FontSizeBody / 2 = + /// assumedFontSize), matching the margin the single-port case already achieves comfortably. + /// + [Fact] + public void Apply_TitledNodeWithTwoLeftPortLabels_BottomPortRetainsDownwardShiftMargin() + { + const double assumedFontSize = 12.0; // CoreOptions.AssumedFontSize default + var graph = new LayoutGraph(); + var sourceA = graph.AddNode("sourceA", 80, 30); + var sourceB = graph.AddNode("sourceB", 80, 30); + var target = graph.AddNode("target", 80, 30); + var hub = graph.AddNode("hub", 200, 60); + hub.Label = "Hub"; + var topLeftPort = hub.Ports.AddPort("telemetry"); + topLeftPort.ExternalLabel = "telemetry"; + var bottomLeftPort = hub.Ports.AddPort("heartbeat"); + bottomLeftPort.ExternalLabel = "heartbeat"; + graph.AddEdge("e1", sourceA, topLeftPort); + graph.AddEdge("e2", sourceB, bottomLeftPort); + graph.AddEdge("e3", hub, target); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var hubBox = tree.Nodes.OfType().Single(b => b.Label == "Hub"); + var ports = tree.Nodes.OfType() + .Where(p => p.Side == PortSide.Left) + .OrderBy(p => p.CentreY) + .ToList(); + Assert.Equal(2, ports.Count); + + var boxBottom = hubBox.Y + hubBox.Height; + var bottomPortMargin = boxBottom - ports[^1].CentreY; + Assert.True( + bottomPortMargin >= assumedFontSize, + $"Expected bottom-most left port's margin to box bottom ({bottomPortMargin}) to be at least " + + $"{assumedFontSize} — enough room for SvgRenderer.EmitPortLabel's own " + + "CentreY + FontSizeBody / 2 downward shift plus the shifted label's own half-height."); + } + + /// + /// Proves that a node with several unlabeled outgoing edges fanning out from one face + /// still grows tall enough to give each connector a minimum clearance slice, even though none + /// of the edges or ports carry any label. Regression coverage for the reported "Motherboard" + /// bug: a face with 2+ anchors used to skip the entire growth floor whenever + /// !faceInfo.AnyLabeled && !sidePortLabeled, so a box with a pile of unlabeled + /// ports never grew to give them room — PortDistributor.DistributePorts still + /// spread the anchors proportionally, but within whatever (possibly tiny) caller-supplied + /// height happened to already exist, visually bunching several connector exit points close + /// together. Asserts the box grows past its small caller-supplied height to at least + /// 2 * ConnectorClearance per anchor (mirroring the labeled-face floor's baseline term), + /// and that adjacent anchor spacing on the fanned-out face is at least that same clearance. + /// + [Fact] + public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight() + { + var graph = new LayoutGraph(); + var board = graph.AddNode("board", 140, 20); + var cpu = graph.AddNode("cpu", 80, 30); + cpu.Label = "cpu"; + var storage = graph.AddNode("storage", 80, 30); + storage.Label = "storage"; + var network = graph.AddNode("network", 80, 30); + network.Label = "network"; + var graphics = graph.AddNode("graphics", 80, 30); + graphics.Label = "graphics"; + graph.AddEdge("e1", board, cpu); + graph.AddEdge("e2", board, storage); + graph.AddEdge("e3", board, network); + graph.AddEdge("e4", board, graphics); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); + var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * 4; + Assert.True( + boardBox.Height >= expectedFloor, + $"Expected board height to grow to at least {expectedFloor} (2 * ConnectorClearance * " + + $"Total) for its 4 unlabeled outgoing anchors, was {boardBox.Height}."); + + // Assert: the fix's user-visible goal — the 4 anchors on the board's own face are actually + // spread apart by at least one ConnectorClearance each, not merely bunched within a taller box. + // (All 4 lines in this graph originate from board, so no extra source-filtering is needed.) + var anchorYs = tree.Nodes.OfType() + .Select(l => l.Waypoints[0].Y) + .OrderBy(y => y) + .ToList(); + Assert.Equal(4, anchorYs.Count); + for (var i = 1; i < anchorYs.Count; i++) + { + Assert.True( + anchorYs[i] - anchorYs[i - 1] >= LayeredLayoutMetrics.ConnectorClearance - 1e-6, + $"Expected adjacent unlabeled anchors to be spaced at least {LayeredLayoutMetrics.ConnectorClearance}px " + + $"apart, was {anchorYs[i] - anchorYs[i - 1]}."); + } + } + + /// + /// Top/Bottom-face mirror of : + /// proves the growth-floor fix applies along the width axis too. The Left/Right branch's + /// unlabeled-face floor was fixed to scale by faceInfo.Total + /// (clearanceBasedHeight), but the Top/Bottom branch's width floor was initially left + /// using the old flat 2 * ConnectorClearance term regardless of port count — reproducing + /// the exact same bunching bug rotated 90 degrees for several unlabeled ports fanning out from + /// a node's bottom face (forced here via ). Asserts the + /// board grows wide enough to give each of 4 unlabeled anchors at least + /// 2 * ConnectorClearance of its own width. + /// + [Fact] + public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth() + { + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + var board = graph.AddNode("board", 20, 20); + var cpu = graph.AddNode("cpu", 30, 30); + cpu.Label = "cpu"; + var storage = graph.AddNode("storage", 30, 30); + storage.Label = "storage"; + var network = graph.AddNode("network", 30, 30); + network.Label = "network"; + var graphics = graph.AddNode("graphics", 30, 30); + graphics.Label = "graphics"; + graph.AddEdge("e1", board, cpu); + graph.AddEdge("e2", board, storage); + graph.AddEdge("e3", board, network); + graph.AddEdge("e4", board, graphics); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); + var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * 4; + Assert.True( + boardBox.Width >= expectedFloor, + $"Expected board width to grow to at least {expectedFloor} (2 * ConnectorClearance * " + + $"Total) for its 4 unlabeled outgoing anchors on the Bottom face, was {boardBox.Width}."); + + // Assert: the fix's user-visible goal — the 4 anchors on the board's own face are actually + // spread apart by at least one ConnectorClearance each, not merely bunched within a wider box. + // (All 4 lines in this graph originate from board, so no extra source-filtering is needed.) + var anchorXs = tree.Nodes.OfType() + .Select(l => l.Waypoints[0].X) + .OrderBy(x => x) + .ToList(); + Assert.Equal(4, anchorXs.Count); + for (var i = 1; i < anchorXs.Count; i++) + { + Assert.True( + anchorXs[i] - anchorXs[i - 1] >= LayeredLayoutMetrics.ConnectorClearance - 1e-6, + $"Expected adjacent unlabeled anchors to be spaced at least {LayeredLayoutMetrics.ConnectorClearance}px " + + $"apart, was {anchorXs[i] - anchorXs[i - 1]}."); + } + } + + /// + /// Proves that a titled node with several unlabeled named ports on one face still + /// excludes the title band from port placement — even though nothing rendered at those anchors + /// could ever collide with the title text, PortDistributor.TitleReserveFor keys + /// its exclusion off LayerNode.TitleReserveTop, which is populated whenever the + /// node — regardless of whether any individual port + /// carries an — so a titled box's unlabeled ports + /// are placed no differently than labeled ones with respect to the title. Combines this with + /// the growth-floor regression above: uses 6 unlabeled ports (rather than 4) so the + /// 2 * ConnectorClearance-per-port floor (120) clearly exceeds the height a titled box + /// would already reach from its title band alone — otherwise the growth assertion would pass + /// even without the fix, since a title reserves height on its own regardless of port count. + /// + [Fact] + public void Apply_TitledNodeWithSeveralUnlabeledPorts_PortsAvoidTitleBandAndAutoGrowsHeight() + { + const int portCount = 6; + var graph = new LayoutGraph(); + var board = graph.AddNode("board", 140, 20); + board.Label = "board : Motherboard"; + + for (var k = 0; k < portCount; k++) + { + var target = graph.AddNode($"target{k}", 80, 30); + var port = board.Ports.AddPort($"p{k}"); // no ExternalLabel set — carries no text at all + graph.AddEdge($"e{k}", port, target); + } + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boardBox = tree.Nodes.OfType().Single(b => b.Label == "board : Motherboard"); + var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * portCount; + Assert.True( + boardBox.Height >= expectedFloor, + $"Expected board height to grow to at least {expectedFloor} (2 * ConnectorClearance * " + + $"Total) for its {portCount} unlabeled ports, was {boardBox.Height}."); + + var layoutPorts = tree.Nodes.OfType().Where(p => p.Side == PortSide.Right).ToList(); + Assert.Equal(portCount, layoutPorts.Count); + + // The title band sits at the very top of the box; every port must be placed strictly below it + // (using a generous lower bound of one title-sized band, matching ResolveTitleReserveTop's + // "roughly double assumedFontSize" comment) even though none of them carry any text. + const double assumedFontSize = 12.0; // CoreOptions.AssumedFontSize default + var titleBandBottom = boardBox.Y + (2.0 * assumedFontSize); + Assert.All( + layoutPorts, + p => Assert.True( + p.CentreY > titleBandBottom, + $"Expected port at CentreY {p.CentreY} to sit below the title band (bottom " + + $"{titleBandBottom}), even though it carries no label.")); + } + /// /// Proves that a caller-supplied node size already large enough to fit its title and port /// insets is left completely unchanged — the auto-grow floor never shrinks a node, and it does