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 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
+ ControllerDriver
- 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 @@
-
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 @@
MonitorAMonitorB
-
- Hub
-
- ControlA
-
- ControlB
+
+ Hub
+
+ ControlA
+
+ ControlBa rather long status feedalert
-
-
- 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
+
+ Controlstatus
-
-
- 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