elksharp: stabilize document-processing terminal routing
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
# Sprint 20260403-002 - ElkSharp Document Processing Routing Fixes
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Fix the document-processing ELKSharp routing faults confirmed during artifact review: fork-to-repeat branch docking, crowded top-corridor ownership, and the email-dispatch terminal bundle into `End`.
|
||||||
|
- Keep the fixes generic to the routing pipeline rather than hard-coding document-processing edge IDs.
|
||||||
|
- Working directory: `src/__Libraries/StellaOps.ElkSharp/`.
|
||||||
|
- Expected evidence: targeted workflow renderer tests, regenerated document-processing PNG/JSON artifacts, and focused regression assertions.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on the current ElkSharp hybrid/finalization pipeline and the discrete boundary-slot contract.
|
||||||
|
- Safe cross-module edits limited to:
|
||||||
|
- `src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/`
|
||||||
|
- `src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/`
|
||||||
|
- Avoid unrelated workflow library changes because the worktree already contains user edits there.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
|
||||||
|
- `docs/code-of-conduct/TESTING_PRACTICES.md`
|
||||||
|
- `docs/workflow/ENGINE.md`
|
||||||
|
- `src/__Libraries/StellaOps.ElkSharp/AGENTS.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### TASK-001 - Repair document-processing routing readability defects
|
||||||
|
Status: DONE
|
||||||
|
Dependency: none
|
||||||
|
Owners: Implementer
|
||||||
|
Task description:
|
||||||
|
- Apply three renderer fixes driven by the reviewed document-processing artifact: dock fork-to-repeat branch entries on a repeat/header target instead of the left-face midpoint, spread above-graph corridor lanes by ownership instead of color-only crowding, and give terminal `End` arrivals a coherent bundle so `Email Dispatch` does not collapse into the mixed face approach.
|
||||||
|
- Add regression assertions in the focused workflow renderer test project to lock the repaired geometry.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] `edge/3` enters `Process Batch` from the header/top band rather than the left-face midpoint
|
||||||
|
- [x] the top corridor keeps distinct lanes for the repeat-return and long terminal sweeps
|
||||||
|
- [x] `edge/30`, `edge/32`, and `edge/33` no longer collapse into the same ambiguous `End` bundle
|
||||||
|
- [x] targeted renderer tests pass on the individual `.csproj`
|
||||||
|
|
||||||
|
### TASK-002 - Repair semantic route-family ownership for the remaining default lanes
|
||||||
|
Status: DONE
|
||||||
|
Dependency: TASK-001
|
||||||
|
Owners: Implementer
|
||||||
|
Task description:
|
||||||
|
- Fix the deeper pipeline faults still visible in the document-processing artifact after TASK-001: the direct `Parallel Execution -> Join` bypass still owns the visual fork mainline, the `Retry Decision` default and `Cooldown Timer` continuation still do not resolve as one readable setter family, and the long `End` arrivals still split between corridor and side-face terminal strategies.
|
||||||
|
- Treat the route-family fix as a cross-layer change. The ElkSharp batching and side-resolution rules must use the same semantic family model, and the SVG renderer must stop collapsing a short horizontal-plus-shallow-diagonal continuation into a tiny vertical stub.
|
||||||
|
- Add failing regression tests first for the fork primary axis, the retry/timer local setter band, the full `End` terminal family, and the SVG-path preservation rule.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] `edge/4` no longer owns the fork primary axis over the work branch into `Process Batch`
|
||||||
|
- [x] `edge/9` and `edge/10` remain in one readable local setter family without a lower detour band
|
||||||
|
- [x] all document-processing arrivals into `End` use one coherent left-face terminal family
|
||||||
|
- [x] the SVG renderer preserves short readable continuations instead of collapsing them into degenerate stubs
|
||||||
|
- [x] targeted renderer tests pass on the individual `.csproj`
|
||||||
|
|
||||||
|
### TASK-003 - Polish render annotations and remove the remaining fork-bypass false positive
|
||||||
|
Status: DONE
|
||||||
|
Dependency: TASK-002
|
||||||
|
Owners: Implementer
|
||||||
|
Task description:
|
||||||
|
- Compact the SVG legend footprint, wrap long condition labels into readable badges, and remove the remaining clean fork-bypass gateway-source false positive from both scoring and artifact inspection.
|
||||||
|
- Re-attempt the document-processing top-corridor and `End` family cleanup only after the current ElkSharp winner-refinement terminal-closure loop can absorb those rewrites without reopening heavy boundary-slot and target-join pressure in the dirty worktree.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] the SVG legend height is content-driven instead of fixed to the previous oversized frame
|
||||||
|
- [x] long condition labels render as wrapped badges instead of one thin over-wide strip
|
||||||
|
- [x] clean orthogonal fork-bypass paths like document-processing `edge/4` are not counted by targeted gateway-source scoring
|
||||||
|
- [x] the document-processing rerender converges with zero gateway-source diagnostics in the latest-artifact inspection path
|
||||||
|
- [x] top-corridor and `End`-family cleanup can be reintroduced without reopening boundary-slot / target-join / under-node pressure
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-04-03 | Sprint created for the confirmed document-processing routing defects after artifact review of the ELKSharp output. | Implementer |
|
||||||
|
| 2026-04-03 | Added semantic target-side docking for fork-to-repeat and non-corridored `End` arrivals, plus above-graph corridor ownership spreading to keep terminal sweeps visually distinct. | Implementer |
|
||||||
|
| 2026-04-03 | Verified targeted renderer evidence on the individual test project: artifact PNG test passed, and focused regression tests passed for branch header docking, top-corridor ownership, and end-bundle separation. | Implementer |
|
||||||
|
| 2026-04-03 | Follow-up review found the remaining root-cause defects: fork bypass mainline ownership, retry/timer setter-family fragmentation, split `End` terminal families, and SVG short-jog collapse for the cooldown continuation. | Implementer |
|
||||||
|
| 2026-04-04 | Completed the remaining route-family work: gateway-source scoring heuristics now suppress protected corridor and clean orthogonal branch exits, gateway vertex exits only count when the departure is actually problematic, and the document-processing artifact plus focused geometry/SVG guards all pass on the renderer test project. | Implementer |
|
||||||
|
| 2026-04-05 | Added content-driven SVG legend sizing, wrapped long edge-condition badges, and a direct `edge/4` fork-bypass gateway-source regression test. Targeted renderer and scorer tests pass on the individual renderer test project. | Implementer |
|
||||||
|
| 2026-04-05 | Attempted to activate the new top-corridor and `End` terminal-family hooks in the live hybrid refinement loop, but captured document-processing rerenders reopened heavy terminal-closure pressure (`boundary-slots`, `target-joins`, `under-node`) and did not converge cleanly. The hook entry points were returned to pass-through and the remaining cleanup moved to TASK-003. | Implementer |
|
||||||
|
| 2026-04-05 | Reintroduced the winner-refinement top-corridor ownership pass with score-gated cluster metrics, reactivated the `End` terminal-family cleanup, and verified the stable document-processing rerender plus latest-artifact inspection. Direct regressions now pass for overlapping repeat/end roof-lane ownership and top-family `End` sharing. | Implementer |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Cross-module edits are limited to the document-processing renderer tests and the SVG renderer so the routing contract and the emitted artifact can be pinned together.
|
||||||
|
- The work must preserve deterministic routing and existing repeat-return clearance guarantees while improving readability.
|
||||||
|
- The remaining cooldown-continuation defect is partly in the SVG path cleanup, so the sprint now includes a tightly scoped renderer fix in addition to ElkSharp routing changes.
|
||||||
|
- Final verification for TASK-002 used the individual renderer test project: the full document-processing artifact regression, the retry/local-setter and terminal-family scenario guards, the latest-artifact inspection probe, and the SVG short-continuation unit test.
|
||||||
|
- TASK-003 initially exposed a real stability risk in the dirty worktree: turning the corridor and `End`-family helpers back on without extra score-gating could reopen terminal-closure pressure and stop the document-processing artifact from converging.
|
||||||
|
- That risk is now resolved by the score-gated top-corridor ownership pass plus the stabilized `End`-family rewrite: the stable 2026-04-05 rerender and latest-artifact inspection both complete with zero gateway-source, boundary-slot, under-node, shared-lane, target-join, and target-backtracking defects.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Archive the sprint after the current ElkSharp worktree is ready for commit sequencing and no additional document-processing routing follow-ups are opened from the remaining soft readability review.
|
||||||
@@ -11,8 +11,11 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
private const double Margin = 32;
|
private const double Margin = 32;
|
||||||
private const double HeaderHeight = 220;
|
private const double HeaderHeight = 220;
|
||||||
private const double LegendTop = 34;
|
private const double LegendTop = 34;
|
||||||
private const double LegendHeight = 160;
|
private const double LegendLeft = 24;
|
||||||
private const double LabelMinTop = LegendTop + LegendHeight + 18;
|
private const double LegendInnerLeft = 48;
|
||||||
|
private const double LegendInnerRight = 24;
|
||||||
|
private const double LegendBottomPadding = 16;
|
||||||
|
private const double LegendSectionGap = 18;
|
||||||
private const double LabelInsetX = 56;
|
private const double LabelInsetX = 56;
|
||||||
private const double LabelInsetRight = 16;
|
private const double LabelInsetRight = 16;
|
||||||
|
|
||||||
@@ -25,9 +28,11 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
|
|
||||||
var bounds = CalculateBounds(layout);
|
var bounds = CalculateBounds(layout);
|
||||||
var width = Math.Max(1328, bounds.Width + (Margin * 2));
|
var width = Math.Max(1328, bounds.Width + (Margin * 2));
|
||||||
var height = Math.Max(320, bounds.Height + HeaderHeight + Margin + 96);
|
var legendLayout = BuildLegendLayout(width, layout);
|
||||||
|
var headerHeight = Math.Max(HeaderHeight, legendLayout.Bottom + 28d);
|
||||||
|
var height = Math.Max(320, bounds.Height + headerHeight + Margin + 96);
|
||||||
var offsetX = Margin - bounds.MinX;
|
var offsetX = Margin - bounds.MinX;
|
||||||
var offsetY = HeaderHeight - bounds.MinY;
|
var offsetY = headerHeight - bounds.MinY;
|
||||||
|
|
||||||
var builder = new StringBuilder();
|
var builder = new StringBuilder();
|
||||||
var edgeLabels = new List<WorkflowRenderEdgeLabelPlacement>();
|
var edgeLabels = new List<WorkflowRenderEdgeLabelPlacement>();
|
||||||
@@ -80,7 +85,7 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
<rect x="0" y="0" width="{Format(width)}" height="{Format(height)}" fill="#f4f7fb" />
|
<rect x="0" y="0" width="{Format(width)}" height="{Format(height)}" fill="#f4f7fb" />
|
||||||
<text x="24" y="24" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="700" fill="#0f172a">{Encode(title)}</text>
|
<text x="24" y="24" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="700" fill="#0f172a">{Encode(title)}</text>
|
||||||
""");
|
""");
|
||||||
RenderLegend(builder, width, layout);
|
RenderLegend(builder, legendLayout);
|
||||||
|
|
||||||
var highways = DetectHighwayGroups(layout);
|
var highways = DetectHighwayGroups(layout);
|
||||||
var highwayByEdgeId = highways.Values
|
var highwayByEdgeId = highways.Values
|
||||||
@@ -228,6 +233,7 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
offsetY,
|
offsetY,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
legendLayout.Bottom + 18d,
|
||||||
labelObstacles,
|
labelObstacles,
|
||||||
edgeLabels));
|
edgeLabels));
|
||||||
}
|
}
|
||||||
@@ -510,29 +516,38 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
stroke-dasharray="2.2 4.8" />
|
stroke-dasharray="2.2 4.8" />
|
||||||
<rect x="{Format(placement.Left)}" y="{Format(placement.Top)}" rx="9" ry="9" width="{Format(placement.Width)}" height="{Format(placement.Height)}"
|
<rect x="{Format(placement.Left)}" y="{Format(placement.Top)}" rx="9" ry="9" width="{Format(placement.Width)}" height="{Format(placement.Height)}"
|
||||||
fill="{placement.Style.LabelFill}" fill-opacity="0.54" stroke="{placement.Style.Stroke}" stroke-opacity="0.5" stroke-width="0.9" />
|
fill="{placement.Style.LabelFill}" fill-opacity="0.54" stroke="{placement.Style.Stroke}" stroke-opacity="0.5" stroke-width="0.9" />
|
||||||
<text x="{Format(placement.CenterX)}" y="{Format(placement.Top + 14)}"
|
""");
|
||||||
|
|
||||||
|
var fontSize = placement.Lines.Count > 1 ? 10.8d : 11.5d;
|
||||||
|
var lineHeight = placement.Lines.Count > 1 ? 12d : 0d;
|
||||||
|
var firstBaseline = placement.Lines.Count > 1
|
||||||
|
? placement.Top + ((placement.Height - ((placement.Lines.Count - 1) * lineHeight)) / 2d) + 4d
|
||||||
|
: placement.Top + 14d;
|
||||||
|
for (var index = 0; index < placement.Lines.Count; index++)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"""
|
||||||
|
<text x="{Format(placement.CenterX)}" y="{Format(firstBaseline + (index * lineHeight))}"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-family="'Segoe UI', sans-serif"
|
font-family="'Segoe UI', sans-serif"
|
||||||
font-size="11.5"
|
font-size="{Format(fontSize)}"
|
||||||
font-weight="700"
|
font-weight="700"
|
||||||
fill="{placement.Style.LabelText}">{Encode(placement.Label)}</text>
|
fill="{placement.Style.LabelText}">{Encode(placement.Lines[index])}</text>
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RenderLegend(StringBuilder builder, double canvasWidth, WorkflowRenderLayoutResult layout)
|
private static LegendLayout BuildLegendLayout(double canvasWidth, WorkflowRenderLayoutResult layout)
|
||||||
{
|
{
|
||||||
var nodeKinds = new HashSet<string>(layout.Nodes.Select(n => n.Kind), StringComparer.OrdinalIgnoreCase);
|
var nodeKinds = new HashSet<string>(layout.Nodes.Select(n => n.Kind), StringComparer.OrdinalIgnoreCase);
|
||||||
var edgeLabels = new HashSet<string>(
|
var edgeLabels = new HashSet<string>(
|
||||||
layout.Edges.Where(e => !string.IsNullOrWhiteSpace(e.Label)).Select(e => e.Label!),
|
layout.Edges.Where(e => !string.IsNullOrWhiteSpace(e.Label)).Select(e => e.Label!),
|
||||||
StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var legendWidth = Math.Min(canvasWidth - 48d, 1260d);
|
var legendWidth = Math.Min(canvasWidth - 48d, 1040d);
|
||||||
builder.AppendLine($"""
|
var maxRight = LegendLeft + legendWidth - LegendInnerRight;
|
||||||
<g>
|
var contentBuilder = new StringBuilder();
|
||||||
<rect x="24" y="{Format(LegendTop)}" rx="14" ry="14" width="{Format(legendWidth)}" height="{Format(LegendHeight)}" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
|
contentBuilder.AppendLine("""
|
||||||
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
|
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
|
||||||
<text x="40" y="78" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
|
|
||||||
</g>
|
|
||||||
""");
|
""");
|
||||||
|
|
||||||
var nodeChips = new List<(string Kind, string Label)>();
|
var nodeChips = new List<(string Kind, string Label)>();
|
||||||
@@ -547,11 +562,18 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
if (nodeKinds.Contains("Repeat")) nodeChips.Add(("Repeat", "Repeat / Loop"));
|
if (nodeKinds.Contains("Repeat")) nodeChips.Add(("Repeat", "Repeat / Loop"));
|
||||||
if (nodeKinds.Contains("Signal")) nodeChips.Add(("Signal", "Signal"));
|
if (nodeKinds.Contains("Signal")) nodeChips.Add(("Signal", "Signal"));
|
||||||
|
|
||||||
var nodeChipX = 118d;
|
var cursorY = 78d;
|
||||||
foreach (var (kind, label) in nodeChips)
|
var maxBottom = 56d;
|
||||||
|
|
||||||
|
if (nodeChips.Count > 0)
|
||||||
{
|
{
|
||||||
RenderLegendNodeChip(builder, nodeChipX, 62, kind, label);
|
contentBuilder.AppendLine($"""
|
||||||
nodeChipX += (label.Length * 7.5d) + 56d;
|
<text x="40" y="{Format(cursorY)}" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
|
||||||
|
""");
|
||||||
|
maxBottom = Math.Max(maxBottom, cursorY);
|
||||||
|
var nodeBottom = RenderWrappedLegendNodeChips(contentBuilder, nodeChips, cursorY + 10d, maxRight);
|
||||||
|
maxBottom = Math.Max(maxBottom, nodeBottom);
|
||||||
|
cursorY = nodeBottom + LegendSectionGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
var badgeChips = new List<(string Kind, string Label)>();
|
var badgeChips = new List<(string Kind, string Label)>();
|
||||||
@@ -565,15 +587,13 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
|
|
||||||
if (badgeChips.Count > 0)
|
if (badgeChips.Count > 0)
|
||||||
{
|
{
|
||||||
builder.AppendLine("""
|
contentBuilder.AppendLine($"""
|
||||||
<text x="40" y="132" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
|
<text x="40" y="{Format(cursorY)}" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
|
||||||
""");
|
""");
|
||||||
var badgeChipX = 102d;
|
maxBottom = Math.Max(maxBottom, cursorY);
|
||||||
foreach (var (kind, label) in badgeChips)
|
var badgeBottom = RenderWrappedLegendBadgeChips(contentBuilder, badgeChips, cursorY + 8d, maxRight);
|
||||||
{
|
maxBottom = Math.Max(maxBottom, badgeBottom);
|
||||||
RenderLegendBadgeChip(builder, badgeChipX, 128, kind, label);
|
cursorY = badgeBottom + LegendSectionGap;
|
||||||
badgeChipX += (label.Length * 7d) + 50d;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasWhenCondition = edgeLabels.Any(l => l.StartsWith("when ", StringComparison.OrdinalIgnoreCase));
|
var hasWhenCondition = edgeLabels.Any(l => l.StartsWith("when ", StringComparison.OrdinalIgnoreCase));
|
||||||
@@ -595,16 +615,116 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
|
|
||||||
if (branchChips.Count > 0)
|
if (branchChips.Count > 0)
|
||||||
{
|
{
|
||||||
builder.AppendLine("""
|
contentBuilder.AppendLine($"""
|
||||||
<text x="40" y="164" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Branch Callouts:</text>
|
<text x="40" y="{Format(cursorY)}" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Branch Callouts:</text>
|
||||||
""");
|
""");
|
||||||
var branchChipX = 152d;
|
maxBottom = Math.Max(maxBottom, cursorY);
|
||||||
foreach (var (color, label) in branchChips)
|
var branchBottom = RenderWrappedLegendBranchChips(contentBuilder, branchChips, cursorY + 8d, maxRight);
|
||||||
|
maxBottom = Math.Max(maxBottom, branchBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
var legendHeight = Math.Max(74d, (maxBottom - LegendTop) + LegendBottomPadding);
|
||||||
|
var legendBuilder = new StringBuilder();
|
||||||
|
legendBuilder.AppendLine($"""
|
||||||
|
<g>
|
||||||
|
<rect x="{Format(LegendLeft)}" y="{Format(LegendTop)}" rx="14" ry="14" width="{Format(legendWidth)}" height="{Format(legendHeight)}" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
|
||||||
|
""");
|
||||||
|
legendBuilder.Append(contentBuilder.ToString());
|
||||||
|
legendBuilder.AppendLine(" </g>");
|
||||||
|
|
||||||
|
return new LegendLayout(legendWidth, legendHeight, legendBuilder.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderLegend(StringBuilder builder, LegendLayout legendLayout)
|
||||||
{
|
{
|
||||||
RenderLegendBranchChip(builder, branchChipX, 160, color, label);
|
builder.Append(legendLayout.Svg);
|
||||||
branchChipX += (label.Length * 7d) + 46d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double RenderWrappedLegendNodeChips(
|
||||||
|
StringBuilder builder,
|
||||||
|
IReadOnlyList<(string Kind, string Label)> chips,
|
||||||
|
double startY,
|
||||||
|
double maxRight)
|
||||||
|
{
|
||||||
|
var x = LegendInnerLeft;
|
||||||
|
var y = startY;
|
||||||
|
foreach (var (kind, label) in chips)
|
||||||
|
{
|
||||||
|
var width = MeasureLegendNodeChipWidth(label);
|
||||||
|
if (x + width > maxRight && x > LegendInnerLeft)
|
||||||
|
{
|
||||||
|
x = LegendInnerLeft;
|
||||||
|
y += 32d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RenderLegendNodeChip(builder, x, y, kind, label);
|
||||||
|
x += width + 16d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return y + 24d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double RenderWrappedLegendBadgeChips(
|
||||||
|
StringBuilder builder,
|
||||||
|
IReadOnlyList<(string Kind, string Label)> chips,
|
||||||
|
double startY,
|
||||||
|
double maxRight)
|
||||||
|
{
|
||||||
|
var x = LegendInnerLeft;
|
||||||
|
var y = startY;
|
||||||
|
foreach (var (kind, label) in chips)
|
||||||
|
{
|
||||||
|
var width = MeasureLegendBadgeChipWidth(label);
|
||||||
|
if (x + width > maxRight && x > LegendInnerLeft)
|
||||||
|
{
|
||||||
|
x = LegendInnerLeft;
|
||||||
|
y += 28d;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderLegendBadgeChip(builder, x + 10.5d, y + 10.5d, kind, label);
|
||||||
|
x += width + 16d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return y + 22d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double RenderWrappedLegendBranchChips(
|
||||||
|
StringBuilder builder,
|
||||||
|
IReadOnlyList<(string Color, string Label)> chips,
|
||||||
|
double startY,
|
||||||
|
double maxRight)
|
||||||
|
{
|
||||||
|
var x = LegendInnerLeft;
|
||||||
|
var y = startY;
|
||||||
|
foreach (var (color, label) in chips)
|
||||||
|
{
|
||||||
|
var width = MeasureLegendBranchChipWidth(label);
|
||||||
|
if (x + width > maxRight && x > LegendInnerLeft)
|
||||||
|
{
|
||||||
|
x = LegendInnerLeft;
|
||||||
|
y += 24d;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderLegendBranchChip(builder, x, y + 10d, color, label);
|
||||||
|
x += width + 14d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return y + 18d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MeasureLegendNodeChipWidth(string label)
|
||||||
|
{
|
||||||
|
return 56d + (label.Length * 7.2d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MeasureLegendBadgeChipWidth(string label)
|
||||||
|
{
|
||||||
|
return 34d + (label.Length * 6.8d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MeasureLegendBranchChipWidth(string label)
|
||||||
|
{
|
||||||
|
return 54d + (label.Length * 6.8d);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (double MinX, double MinY, double Width, double Height) CalculateBounds(WorkflowRenderLayoutResult layout)
|
private static (double MinX, double MinY, double Width, double Height) CalculateBounds(WorkflowRenderLayoutResult layout)
|
||||||
@@ -860,6 +980,24 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
return $"{value[..(maxCharsPerLine - 3)]}...";
|
return $"{value[..(maxCharsPerLine - 3)]}...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string[] WrapEdgeLabelLines(string label)
|
||||||
|
{
|
||||||
|
var normalized = label.Replace('\r', ' ').Replace('\n', ' ');
|
||||||
|
var wrapped = WrapSingleLine(normalized, 28)
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||||
|
.ToList();
|
||||||
|
if (wrapped.Count <= 2)
|
||||||
|
{
|
||||||
|
return wrapped.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
wrapped[0],
|
||||||
|
TruncateSingleLine(string.Join(" ", wrapped.Skip(1)), 28),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private static string Encode(string value)
|
private static string Encode(string value)
|
||||||
{
|
{
|
||||||
return WebUtility.HtmlEncode(value);
|
return WebUtility.HtmlEncode(value);
|
||||||
@@ -878,12 +1016,14 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
double offsetY,
|
double offsetY,
|
||||||
double canvasWidth,
|
double canvasWidth,
|
||||||
double canvasHeight,
|
double canvasHeight,
|
||||||
|
double labelMinTop,
|
||||||
IReadOnlyCollection<WorkflowRenderRect> nodeObstacles,
|
IReadOnlyCollection<WorkflowRenderRect> nodeObstacles,
|
||||||
IReadOnlyCollection<WorkflowRenderEdgeLabelPlacement> placedLabels)
|
IReadOnlyCollection<WorkflowRenderEdgeLabelPlacement> placedLabels)
|
||||||
{
|
{
|
||||||
var renderedLabel = TruncateSingleLine(label, 50);
|
var renderedLines = WrapEdgeLabelLines(label);
|
||||||
var width = Math.Min(368d, Math.Max(92d, (renderedLabel.Length * 6.35d) + 10d));
|
var longestLineLength = renderedLines.Max(static line => line.Length);
|
||||||
var height = 22d;
|
var width = Math.Min(252d, Math.Max(108d, (longestLineLength * 6.35d) + 18d));
|
||||||
|
var height = renderedLines.Length == 1 ? 22d : 36d;
|
||||||
var isErrorLabel = label.Contains("failure", StringComparison.OrdinalIgnoreCase)
|
var isErrorLabel = label.Contains("failure", StringComparison.OrdinalIgnoreCase)
|
||||||
|| label.Contains("timeout", StringComparison.OrdinalIgnoreCase);
|
|| label.Contains("timeout", StringComparison.OrdinalIgnoreCase);
|
||||||
var segment = ResolveLabelAnchorSegment(points);
|
var segment = ResolveLabelAnchorSegment(points);
|
||||||
@@ -912,10 +1052,10 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
var bestOverlapArea = double.MaxValue;
|
var bestOverlapArea = double.MaxValue;
|
||||||
var bestDistance = double.MaxValue;
|
var bestDistance = double.MaxValue;
|
||||||
|
|
||||||
foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight))
|
foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight, labelMinTop))
|
||||||
{
|
{
|
||||||
var placement = new WorkflowRenderEdgeLabelPlacement(
|
var placement = new WorkflowRenderEdgeLabelPlacement(
|
||||||
renderedLabel,
|
renderedLines,
|
||||||
edgeStyle,
|
edgeStyle,
|
||||||
anchorX,
|
anchorX,
|
||||||
anchorY,
|
anchorY,
|
||||||
@@ -946,12 +1086,12 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
|
|
||||||
return bestPlacement
|
return bestPlacement
|
||||||
?? new WorkflowRenderEdgeLabelPlacement(
|
?? new WorkflowRenderEdgeLabelPlacement(
|
||||||
renderedLabel,
|
renderedLines,
|
||||||
edgeStyle,
|
edgeStyle,
|
||||||
anchorX,
|
anchorX,
|
||||||
anchorY,
|
anchorY,
|
||||||
Clamp(anchorX - (width / 2d), 24d, Math.Max(24d, canvasWidth - 24d - width)),
|
Clamp(anchorX - (width / 2d), 24d, Math.Max(24d, canvasWidth - 24d - width)),
|
||||||
Clamp(anchorY - height - 54d, LabelMinTop, Math.Max(LabelMinTop, canvasHeight - 24d - height)),
|
Clamp(anchorY - height - 54d, labelMinTop, Math.Max(labelMinTop, canvasHeight - 24d - height)),
|
||||||
width,
|
width,
|
||||||
height);
|
height);
|
||||||
}
|
}
|
||||||
@@ -963,20 +1103,21 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
double height,
|
double height,
|
||||||
bool horizontal,
|
bool horizontal,
|
||||||
double canvasWidth,
|
double canvasWidth,
|
||||||
double canvasHeight)
|
double canvasHeight,
|
||||||
|
double labelMinTop)
|
||||||
{
|
{
|
||||||
static double ResolveTopBound(double canvasHeightLocal, double heightLocal)
|
static double ResolveTopBound(double canvasHeightLocal, double heightLocal, double labelMinTopLocal)
|
||||||
{
|
{
|
||||||
return Math.Max(LabelMinTop, canvasHeightLocal - 24d - heightLocal);
|
return Math.Max(labelMinTopLocal, canvasHeightLocal - 24d - heightLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
(double Left, double Top) Normalize(double left, double top)
|
(double Left, double Top) Normalize(double left, double top)
|
||||||
{
|
{
|
||||||
var maxLeft = Math.Max(24d, canvasWidth - 24d - width);
|
var maxLeft = Math.Max(24d, canvasWidth - 24d - width);
|
||||||
var maxTop = ResolveTopBound(canvasHeight, height);
|
var maxTop = ResolveTopBound(canvasHeight, height, labelMinTop);
|
||||||
return (
|
return (
|
||||||
Clamp(left, 24d, maxLeft),
|
Clamp(left, 24d, maxLeft),
|
||||||
Clamp(top, LabelMinTop, maxTop));
|
Clamp(top, labelMinTop, maxTop));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var level = 0; level < 5; level++)
|
for (var level = 0; level < 5; level++)
|
||||||
@@ -2239,7 +2380,10 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
if (segLen < 30d && i < mutablePoints.Count - 1)
|
if (segLen < 30d && i < mutablePoints.Count - 1)
|
||||||
{
|
{
|
||||||
var next = mutablePoints[i + 1];
|
var next = mutablePoints[i + 1];
|
||||||
if (dxIn < dyIn)
|
var dxOut = Math.Abs(next.X - curr.X);
|
||||||
|
var dyOut = Math.Abs(next.Y - curr.Y);
|
||||||
|
var preserveHorizontalContinuation = dxOut >= dyOut;
|
||||||
|
if (preserveHorizontalContinuation)
|
||||||
{
|
{
|
||||||
mutablePoints[i + 1] = new WorkflowRenderPoint { X = next.X, Y = prev.Y };
|
mutablePoints[i + 1] = new WorkflowRenderPoint { X = next.X, Y = prev.Y };
|
||||||
}
|
}
|
||||||
@@ -2321,7 +2465,7 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
string LabelText);
|
string LabelText);
|
||||||
|
|
||||||
private sealed record WorkflowRenderEdgeLabelPlacement(
|
private sealed record WorkflowRenderEdgeLabelPlacement(
|
||||||
string Label,
|
IReadOnlyList<string> Lines,
|
||||||
WorkflowRenderEdgeStyle Style,
|
WorkflowRenderEdgeStyle Style,
|
||||||
double AnchorX,
|
double AnchorX,
|
||||||
double AnchorY,
|
double AnchorY,
|
||||||
@@ -2334,6 +2478,14 @@ public sealed class WorkflowRenderSvgRenderer
|
|||||||
public double CenterY => Top + (Height / 2d);
|
public double CenterY => Top + (Height / 2d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record LegendLayout(
|
||||||
|
double Width,
|
||||||
|
double Height,
|
||||||
|
string Svg)
|
||||||
|
{
|
||||||
|
public double Bottom => LegendTop + Height;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record WorkflowRenderRect(
|
private sealed record WorkflowRenderRect(
|
||||||
double Left,
|
double Left,
|
||||||
double Top,
|
double Top,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ using NUnit.Framework;
|
|||||||
|
|
||||||
using StellaOps.ElkSharp;
|
using StellaOps.ElkSharp;
|
||||||
using StellaOps.Workflow.Abstractions;
|
using StellaOps.Workflow.Abstractions;
|
||||||
|
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||||
|
using StellaOps.Workflow.Renderer.Svg;
|
||||||
|
|
||||||
namespace StellaOps.Workflow.Renderer.Tests;
|
namespace StellaOps.Workflow.Renderer.Tests;
|
||||||
|
|
||||||
@@ -13,14 +15,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
|
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
|
||||||
{
|
{
|
||||||
var workflowRenderingsDirectory = Path.Combine(
|
var outputDir = RenderLatestElkSharpArtifactForInspection();
|
||||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
|
||||||
"TestResults",
|
|
||||||
"workflow-renderings");
|
|
||||||
var outputDir = Directory.GetDirectories(workflowRenderingsDirectory)
|
|
||||||
.OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal)
|
|
||||||
.Select(path => Path.Combine(path, "DocumentProcessingWorkflow"))
|
|
||||||
.First(Directory.Exists);
|
|
||||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||||
Assert.That(File.Exists(jsonPath), Is.True);
|
Assert.That(File.Exists(jsonPath), Is.True);
|
||||||
|
|
||||||
@@ -259,6 +254,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
TestContext.Out.WriteLine(
|
TestContext.Out.WriteLine(
|
||||||
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||||
}
|
}
|
||||||
|
Assert.That(gatewaySourceCount, Is.EqualTo(0), "Latest artifact should not report residual gateway-source violations.");
|
||||||
|
|
||||||
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -514,4 +510,51 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string RenderLatestElkSharpArtifactForInspection()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
var outputDir = Path.Combine(
|
||||||
|
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||||
|
"TestResults",
|
||||||
|
"workflow-renderings",
|
||||||
|
DateTime.Today.ToString("yyyyMMdd"),
|
||||||
|
"DocumentProcessingWorkflow");
|
||||||
|
Directory.CreateDirectory(outputDir);
|
||||||
|
|
||||||
|
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||||
|
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||||
|
if (File.Exists(progressLogPath))
|
||||||
|
{
|
||||||
|
File.Delete(progressLogPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||||
|
if (File.Exists(diagnosticsPath))
|
||||||
|
{
|
||||||
|
File.Delete(diagnosticsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||||
|
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||||
|
|
||||||
|
var layout = engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
}).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||||
|
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||||
|
File.WriteAllText(Path.Combine(outputDir, "elksharp.svg"), svgDoc.Svg);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(outputDir, "elksharp.json"),
|
||||||
|
JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
File.WriteAllText(
|
||||||
|
diagnosticsPath,
|
||||||
|
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|
||||||
|
return outputDir;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -198,13 +198,14 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||||
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
Assert.That(sharedLaneOffenders, Is.Empty, "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
||||||
|
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.GatewaySourceExitViolations, Is.EqualTo(0), "Selected layout must not leave soft gateway-source false positives in the final diagnostic score.");
|
||||||
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
||||||
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||||
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||||
|
|||||||
@@ -247,6 +247,31 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
path.Max(point => point.Y),
|
path.Max(point => point.Y),
|
||||||
Is.LessThanOrEqualTo(maxAllowedY),
|
Is.LessThanOrEqualTo(maxAllowedY),
|
||||||
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
||||||
|
|
||||||
|
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||||
|
var elkEdges = layout.Edges.Select(routedEdge => new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = routedEdge.Id,
|
||||||
|
SourceNodeId = routedEdge.SourceNodeId,
|
||||||
|
TargetNodeId = routedEdge.TargetNodeId,
|
||||||
|
Kind = routedEdge.Kind,
|
||||||
|
Label = routedEdge.Label,
|
||||||
|
Sections = routedEdge.Sections.Select(section => new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||||
|
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||||
|
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||||
|
}).ToArray(),
|
||||||
|
}).ToArray();
|
||||||
|
var repeatReturnEdges = elkEdges
|
||||||
|
.Where(routedEdge => routedEdge.TargetNodeId == "start/2/branch-1/1"
|
||||||
|
&& (routedEdge.Id == "edge/14" || routedEdge.Id == "edge/15" || routedEdge.Id == "edge/35"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
ElkEdgeRoutingScoring.CountUnderNodeViolations(repeatReturnEdges, elkNodes),
|
||||||
|
Is.EqualTo(0),
|
||||||
|
"Repeat returns into Process Batch should stay above the Parallel Execution join field instead of routing under it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -283,4 +308,431 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
Is.EqualTo(0),
|
Is.EqualTo(0),
|
||||||
$"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}");
|
$"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldDockForkBranchIntoProcessBatchHeaderBand()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/3");
|
||||||
|
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||||
|
var path = FlattenPath(edge);
|
||||||
|
|
||||||
|
Assert.That(path.Count, Is.GreaterThanOrEqualTo(3));
|
||||||
|
Assert.That(
|
||||||
|
ResolveBoundarySide(path[^1], targetNode),
|
||||||
|
Is.EqualTo("top"),
|
||||||
|
"Fork branch lanes into Process Batch should dock into the repeat header band instead of the left-face midpoint.");
|
||||||
|
Assert.That(
|
||||||
|
path[^1].Y,
|
||||||
|
Is.EqualTo(targetNode.Y).Within(0.5d),
|
||||||
|
"The Process Batch branch endpoint should land on the top boundary.");
|
||||||
|
Assert.That(
|
||||||
|
path[^2].X,
|
||||||
|
Is.EqualTo(path[^1].X).Within(0.5d),
|
||||||
|
"The final Process Batch branch segment should be a direct vertical drop into the header band.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldShareOneAboveGraphTerminalHighwayIntoEnd()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
var graphMinY = layout.Nodes.Min(node => node.Y);
|
||||||
|
|
||||||
|
static double FindAboveGraphLaneY(WorkflowRenderRoutedEdge edge, double graphMinY)
|
||||||
|
{
|
||||||
|
var path = FlattenPath(edge);
|
||||||
|
var bestLength = double.NegativeInfinity;
|
||||||
|
var bestY = double.NaN;
|
||||||
|
for (var i = 0; i < path.Count - 1; i++)
|
||||||
|
{
|
||||||
|
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|
||||||
|
|| path[i].Y >= graphMinY - 8d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = Math.Abs(path[i + 1].X - path[i].X);
|
||||||
|
if (length <= bestLength)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestLength = length;
|
||||||
|
bestY = path[i].Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.That(double.IsNaN(bestY), Is.False, $"Expected an above-graph corridor lane for {edge.Id}.");
|
||||||
|
return bestY;
|
||||||
|
}
|
||||||
|
|
||||||
|
var failureLaneY = FindAboveGraphLaneY(layout.Edges.Single(edge => edge.Id == "edge/20"), graphMinY);
|
||||||
|
var defaultLaneY = FindAboveGraphLaneY(layout.Edges.Single(edge => edge.Id == "edge/23"), graphMinY);
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
Math.Abs(failureLaneY - defaultLaneY),
|
||||||
|
Is.LessThanOrEqualTo(1d),
|
||||||
|
"Long terminal sweeps into End should converge onto one shared terminal highway instead of splitting into color-only roof lanes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepEmailDispatchTerminalBundleDistinctAtEnd()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
var endNode = layout.Nodes.Single(node => node.Id == "end");
|
||||||
|
var terminalEdges = new[]
|
||||||
|
{
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/30"),
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/32"),
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/33"),
|
||||||
|
};
|
||||||
|
var terminalEndpointYs = terminalEdges
|
||||||
|
.Select(edge =>
|
||||||
|
{
|
||||||
|
var path = FlattenPath(edge);
|
||||||
|
Assert.That(
|
||||||
|
ResolveBoundarySide(path[^1], endNode),
|
||||||
|
Is.EqualTo("left"),
|
||||||
|
$"Terminal end arrivals should converge on a coherent left-face bundle for {edge.Id}.");
|
||||||
|
return path[^1].Y;
|
||||||
|
})
|
||||||
|
.OrderBy(value => value)
|
||||||
|
.ToArray();
|
||||||
|
var terminalGap = terminalEndpointYs
|
||||||
|
.Zip(terminalEndpointYs.Skip(1), (upper, lower) => lower - upper)
|
||||||
|
.DefaultIfEmpty(double.MaxValue)
|
||||||
|
.Min();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
terminalGap,
|
||||||
|
Is.GreaterThanOrEqualTo(24d),
|
||||||
|
"The email-dispatch terminal bundle should keep distinct end-face slots.");
|
||||||
|
|
||||||
|
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||||
|
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = edge.Id,
|
||||||
|
SourceNodeId = edge.SourceNodeId,
|
||||||
|
TargetNodeId = edge.TargetNodeId,
|
||||||
|
Kind = edge.Kind,
|
||||||
|
Label = edge.Label,
|
||||||
|
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||||
|
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||||
|
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||||
|
}).ToArray(),
|
||||||
|
}).ToArray();
|
||||||
|
var terminalIds = new HashSet<string>(terminalEdges.Select(edge => edge.Id), StringComparer.Ordinal);
|
||||||
|
var sharedTerminalConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||||
|
.Where(conflict => terminalIds.Contains(conflict.LeftEdgeId) && terminalIds.Contains(conflict.RightEdgeId))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
sharedTerminalConflicts,
|
||||||
|
Is.Empty,
|
||||||
|
"Email dispatch terminal edges must not collapse into a shared lane near End.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldPreferTheWorkBranchOnTheForkPrimaryAxis()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
var splitNode = layout.Nodes.Single(node => node.Id == "start/2/split");
|
||||||
|
var workBranch = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/3"));
|
||||||
|
var bypassBranch = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/4"));
|
||||||
|
var splitCenterY = splitNode.Y + (splitNode.Height / 2d);
|
||||||
|
var workOffset = Math.Abs(workBranch[0].Y - splitCenterY);
|
||||||
|
var bypassOffset = Math.Abs(bypassBranch[0].Y - splitCenterY);
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
workOffset,
|
||||||
|
Is.LessThan(bypassOffset),
|
||||||
|
"The direct Parallel Execution -> Join bypass must not own the fork primary axis over the work branch into Process Batch.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotCountCleanForkBypassAsGatewaySourceViolation()
|
||||||
|
{
|
||||||
|
var elkNodes = new[]
|
||||||
|
{
|
||||||
|
new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/2/split",
|
||||||
|
Label = "Parallel Execution",
|
||||||
|
Kind = "Fork",
|
||||||
|
X = 652d,
|
||||||
|
Y = 127.1552734375d,
|
||||||
|
Width = 176d,
|
||||||
|
Height = 124d,
|
||||||
|
},
|
||||||
|
new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/2/branch-1/1",
|
||||||
|
Label = "Process Batch",
|
||||||
|
Kind = "Repeat",
|
||||||
|
X = 992d,
|
||||||
|
Y = 268.310546875d,
|
||||||
|
Width = 208d,
|
||||||
|
Height = 88d,
|
||||||
|
},
|
||||||
|
new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/2/join",
|
||||||
|
Label = "Parallel Execution Join",
|
||||||
|
Kind = "Join",
|
||||||
|
X = 1290d,
|
||||||
|
Y = 188.73291015625d,
|
||||||
|
Width = 176d,
|
||||||
|
Height = 124d,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var elkEdges = new[]
|
||||||
|
{
|
||||||
|
new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/3",
|
||||||
|
SourceNodeId = "start/2/split",
|
||||||
|
TargetNodeId = "start/2/branch-1/1",
|
||||||
|
Label = "branch 1",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 828d, Y = 189.1552734375d },
|
||||||
|
EndPoint = new ElkPoint { X = 1016d, Y = 268.310546875d },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 1016d, Y = 189.1552734375d },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/4",
|
||||||
|
SourceNodeId = "start/2/split",
|
||||||
|
TargetNodeId = "start/2/join",
|
||||||
|
Label = "branch 2",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 740d, Y = 135.1552734375d },
|
||||||
|
EndPoint = new ElkPoint { X = 1378d, Y = 196.73291015625d },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 740d, Y = 55.1552734375d },
|
||||||
|
new ElkPoint { X = 1378d, Y = 55.1552734375d },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
|
||||||
|
elkEdges,
|
||||||
|
elkNodes,
|
||||||
|
severityByEdgeId,
|
||||||
|
1);
|
||||||
|
|
||||||
|
Assert.That(gatewaySourceCount, Is.EqualTo(0));
|
||||||
|
Assert.That(severityByEdgeId.Keys, Does.Not.Contain("edge/4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepRetryDefaultWithinTheLocalSetterBand()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
var retryDefault = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/9"));
|
||||||
|
var cooldownTimer = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/1/true/1");
|
||||||
|
var batchFailed = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/2");
|
||||||
|
var maxAllowedY = Math.Max(
|
||||||
|
cooldownTimer.Y + cooldownTimer.Height,
|
||||||
|
batchFailed.Y + batchFailed.Height) + 24d;
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
retryDefault.Max(point => point.Y),
|
||||||
|
Is.LessThanOrEqualTo(maxAllowedY),
|
||||||
|
"Retry Decision default should stay in the local setter family instead of dropping into a lower detour band under Cooldown Timer.");
|
||||||
|
|
||||||
|
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||||
|
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = edge.Id,
|
||||||
|
SourceNodeId = edge.SourceNodeId,
|
||||||
|
TargetNodeId = edge.TargetNodeId,
|
||||||
|
Kind = edge.Kind,
|
||||||
|
Label = edge.Label,
|
||||||
|
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||||
|
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||||
|
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||||
|
}).ToArray(),
|
||||||
|
}).ToArray();
|
||||||
|
var brokenSetterHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes)
|
||||||
|
.Where(diagnostic => string.Equals(
|
||||||
|
diagnostic.TargetNodeId,
|
||||||
|
"start/2/branch-1/1/body/4/failure/2",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
brokenSetterHighways,
|
||||||
|
Is.Empty,
|
||||||
|
"Retry Decision default and Cooldown Timer continuation should not collapse into a broken short-highway at Set batchGenerateFailed.");
|
||||||
|
|
||||||
|
var retryDefaultEdge = elkEdges.Single(edge => edge.Id == "edge/9");
|
||||||
|
var cooldownEdge = elkEdges.Single(edge => edge.Id == "edge/8");
|
||||||
|
Assert.That(
|
||||||
|
ElkEdgeRoutingScoring.CountUnderNodeViolations([retryDefaultEdge], elkNodes),
|
||||||
|
Is.EqualTo(0),
|
||||||
|
"Retry Decision default should stay clear of Cooldown Timer instead of tucking underneath it.");
|
||||||
|
Assert.That(
|
||||||
|
ElkEdgeRoutingScoring.DetectSharedLaneConflicts([cooldownEdge, retryDefaultEdge], elkNodes),
|
||||||
|
Is.Empty,
|
||||||
|
"Retry Decision default should keep a distinct departure lane instead of sharing Cooldown Timer's rail.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepAllTerminalArrivalsOnTheEndLeftFaceFamily()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
var endNode = layout.Nodes.Single(node => node.Id == "end");
|
||||||
|
var terminalEdges = new[]
|
||||||
|
{
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/20"),
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/23"),
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/30"),
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/32"),
|
||||||
|
layout.Edges.Single(edge => edge.Id == "edge/33"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var edge in terminalEdges)
|
||||||
|
{
|
||||||
|
var path = FlattenPath(edge);
|
||||||
|
Assert.That(
|
||||||
|
ResolveBoundarySide(path[^1], endNode),
|
||||||
|
Is.EqualTo("left"),
|
||||||
|
$"Terminal arrival {edge.Id} should join the same left-face End family as the shorter email-dispatch arrivals.");
|
||||||
|
Assert.That(
|
||||||
|
ResolveTargetApproachJoinSide(path, endNode),
|
||||||
|
Is.EqualTo("left"),
|
||||||
|
$"Terminal arrival {edge.Id} should approach End from the left-side family instead of curling in from the top/right.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||||
|
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = edge.Id,
|
||||||
|
SourceNodeId = edge.SourceNodeId,
|
||||||
|
TargetNodeId = edge.TargetNodeId,
|
||||||
|
Kind = edge.Kind,
|
||||||
|
Label = edge.Label,
|
||||||
|
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||||
|
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||||
|
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||||
|
}).ToArray(),
|
||||||
|
}).ToArray();
|
||||||
|
var terminalIds = new HashSet<string>(terminalEdges.Select(edge => edge.Id), StringComparer.Ordinal);
|
||||||
|
var sharedTerminalConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||||
|
.Where(conflict => terminalIds.Contains(conflict.LeftEdgeId) && terminalIds.Contains(conflict.RightEdgeId))
|
||||||
|
.ToArray();
|
||||||
|
var brokenTerminalHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes)
|
||||||
|
.Where(diagnostic => string.Equals(diagnostic.TargetNodeId, "end", StringComparison.Ordinal))
|
||||||
|
.ToArray();
|
||||||
|
var terminalJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes)
|
||||||
|
.Where(offender => terminalIds.Any(edgeId => offender.Contains(edgeId, StringComparison.Ordinal)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
sharedTerminalConflicts,
|
||||||
|
Is.Empty,
|
||||||
|
"All End arrivals should resolve into one coherent terminal family instead of a split corridor-versus-side-face strategy.");
|
||||||
|
var topTerminalHighwayEdges = elkEdges
|
||||||
|
.Where(edge => edge.Id is "edge/20" or "edge/23")
|
||||||
|
.ToArray();
|
||||||
|
Assert.That(
|
||||||
|
ElkEdgeRoutingScoring.CountUnderNodeViolations(topTerminalHighwayEdges, elkNodes),
|
||||||
|
Is.EqualTo(0),
|
||||||
|
"The top-family End arrivals should stay on the above-graph terminal highway instead of dropping into an under-node horizontal before End.");
|
||||||
|
Assert.That(
|
||||||
|
terminalJoinOffenders,
|
||||||
|
Is.Empty,
|
||||||
|
"All End arrivals should keep distinct left-face feeder bands instead of collapsing into target-side joins near End.");
|
||||||
|
Assert.That(
|
||||||
|
brokenTerminalHighways,
|
||||||
|
Is.Empty,
|
||||||
|
"All End arrivals should share a coherent terminal highway instead of fragmenting into short, broken target-side bundles.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepProcessBatchExitOrthogonalIntoParallelJoin()
|
||||||
|
{
|
||||||
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
|
||||||
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
NodeSpacing = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
var processBatchExit = layout.Edges.Single(edge => edge.Id == "edge/17");
|
||||||
|
var boundaryAngleOffenders = GetBoundaryAngleViolations(processBatchExit, layout.Nodes).ToArray();
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
boundaryAngleOffenders,
|
||||||
|
Is.Empty,
|
||||||
|
"Process Batch -> Parallel Execution Join must keep an orthogonal gateway approach instead of collapsing into a diagonal join entry.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1294,4 +1294,313 @@ public partial class ElkSharpEdgeRefinementTests
|
|||||||
repairedLowerPath.Should().NotContain(point => Math.Abs(point.X - 4840d) <= 0.5d && point.Y < 697.3528d);
|
repairedLowerPath.Should().NotContain(point => Math.Abs(point.X - 4840d) <= 0.5d && point.Y < 697.3528d);
|
||||||
repairedLowerPath.Should().Contain(point => point.X < 4840d && point.Y < 697.3528d);
|
repairedLowerPath.Should().Contain(point => point.X < 4840d && point.Y < 697.3528d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Property("Intent", "Operational")]
|
||||||
|
public void TopCorridorOwnership_WhenRepeatAndEndFamiliesOverlap_ShouldKeepRepeatClosestAndShareEndRoofLane()
|
||||||
|
{
|
||||||
|
var repeatTarget = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "repeat",
|
||||||
|
Label = "Process Batch",
|
||||||
|
Kind = "Repeat",
|
||||||
|
X = 992,
|
||||||
|
Y = 247.181640625,
|
||||||
|
Width = 208,
|
||||||
|
Height = 88,
|
||||||
|
};
|
||||||
|
|
||||||
|
var repeatSource = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "check",
|
||||||
|
Label = "Check Result",
|
||||||
|
Kind = "Decision",
|
||||||
|
X = 4240,
|
||||||
|
Y = 297.4360656738281,
|
||||||
|
Width = 188,
|
||||||
|
Height = 132,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sourceFailure = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/3",
|
||||||
|
Label = "Load Configuration",
|
||||||
|
Kind = "TransportCall",
|
||||||
|
X = 3200,
|
||||||
|
Y = 120,
|
||||||
|
Width = 208,
|
||||||
|
Height = 88,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sourceDefault = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/9/true/1/true/1/handled/1",
|
||||||
|
Label = "Set internalNotificationFailed",
|
||||||
|
Kind = "SetState",
|
||||||
|
X = 3560,
|
||||||
|
Y = 356,
|
||||||
|
Width = 208,
|
||||||
|
Height = 88,
|
||||||
|
};
|
||||||
|
|
||||||
|
var end = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "end",
|
||||||
|
Label = "End",
|
||||||
|
Kind = "End",
|
||||||
|
X = 5000,
|
||||||
|
Y = 404,
|
||||||
|
Width = 264,
|
||||||
|
Height = 132,
|
||||||
|
};
|
||||||
|
|
||||||
|
var repeatReturn = new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/14",
|
||||||
|
SourceNodeId = repeatSource.Id,
|
||||||
|
TargetNodeId = repeatTarget.Id,
|
||||||
|
Label = "repeat while state.printInsisAttempt eq 0",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 4412.082354488216, Y = 358.4633486927404 },
|
||||||
|
EndPoint = new ElkPoint { X = 1096, Y = 247.181640625 },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 4398, Y = 358.4633486927404 },
|
||||||
|
new ElkPoint { X = 4398, Y = 80 },
|
||||||
|
new ElkPoint { X = 1096, Y = 80 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var failureArrival = new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/20",
|
||||||
|
SourceNodeId = sourceFailure.Id,
|
||||||
|
TargetNodeId = end.Id,
|
||||||
|
Label = "on failure / timeout",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 3408, Y = 164 },
|
||||||
|
EndPoint = new ElkPoint { X = 5000, Y = 438 },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 3408, Y = 64 },
|
||||||
|
new ElkPoint { X = 4620, Y = 64 },
|
||||||
|
new ElkPoint { X = 4620, Y = 438 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var defaultArrival = new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/23",
|
||||||
|
SourceNodeId = sourceDefault.Id,
|
||||||
|
TargetNodeId = end.Id,
|
||||||
|
Label = "default",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 3768, Y = 400 },
|
||||||
|
EndPoint = new ElkPoint { X = 5000, Y = 472 },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 3768, Y = 100 },
|
||||||
|
new ElkPoint { X = 4548, Y = 100 },
|
||||||
|
new ElkPoint { X = 4548, Y = 472 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY)
|
||||||
|
{
|
||||||
|
var path = ExtractPath(edge);
|
||||||
|
var bestLength = double.NegativeInfinity;
|
||||||
|
var bestY = double.NaN;
|
||||||
|
for (var i = 0; i < path.Count - 1; i++)
|
||||||
|
{
|
||||||
|
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|
||||||
|
|| path[i].Y >= graphMinY - 8d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = Math.Abs(path[i + 1].X - path[i].X);
|
||||||
|
if (length <= bestLength)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestLength = length;
|
||||||
|
bestY = path[i].Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
double.IsNaN(bestY).Should().BeFalse();
|
||||||
|
return bestY;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes = new[] { repeatTarget, repeatSource, sourceFailure, sourceDefault, end };
|
||||||
|
var edges = new[] { repeatReturn, failureArrival, defaultArrival };
|
||||||
|
var graphMinY = nodes.Min(node => node.Y);
|
||||||
|
FindAboveGraphLaneY(repeatReturn, graphMinY).Should().BeGreaterThan(FindAboveGraphLaneY(failureArrival, graphMinY));
|
||||||
|
Math.Abs(FindAboveGraphLaneY(failureArrival, graphMinY) - FindAboveGraphLaneY(defaultArrival, graphMinY))
|
||||||
|
.Should()
|
||||||
|
.BeGreaterThan(1d);
|
||||||
|
|
||||||
|
var repaired = ElkTopCorridorOwnership.SpreadAboveGraphCorridorLanes(edges, nodes, 53d);
|
||||||
|
var repairedRepeat = repaired.Single(edge => edge.Id == "edge/14");
|
||||||
|
var repairedFailure = repaired.Single(edge => edge.Id == "edge/20");
|
||||||
|
var repairedDefault = repaired.Single(edge => edge.Id == "edge/23");
|
||||||
|
|
||||||
|
var repairedRepeatY = FindAboveGraphLaneY(repairedRepeat, graphMinY);
|
||||||
|
var repairedFailureY = FindAboveGraphLaneY(repairedFailure, graphMinY);
|
||||||
|
var repairedDefaultY = FindAboveGraphLaneY(repairedDefault, graphMinY);
|
||||||
|
|
||||||
|
repairedRepeatY.Should().BeGreaterThan(repairedFailureY);
|
||||||
|
repairedRepeatY.Should().BeGreaterThan(repairedDefaultY);
|
||||||
|
Math.Abs(repairedFailureY - repairedDefaultY).Should().BeLessThanOrEqualTo(1d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Property("Intent", "Operational")]
|
||||||
|
public void EndTerminalFamilyHelpers_WhenTopFamilyIsSplitAcrossRoofLanes_ShouldShareOneAboveGraphHighway()
|
||||||
|
{
|
||||||
|
var sourceFailure = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/3",
|
||||||
|
Label = "Load Configuration",
|
||||||
|
Kind = "TransportCall",
|
||||||
|
X = 3200,
|
||||||
|
Y = 120,
|
||||||
|
Width = 208,
|
||||||
|
Height = 88,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sourceDefault = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start/9/true/1/true/1/handled/1",
|
||||||
|
Label = "Set internalNotificationFailed",
|
||||||
|
Kind = "SetState",
|
||||||
|
X = 3560,
|
||||||
|
Y = 356,
|
||||||
|
Width = 208,
|
||||||
|
Height = 88,
|
||||||
|
};
|
||||||
|
|
||||||
|
var target = new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = "end",
|
||||||
|
Label = "End",
|
||||||
|
Kind = "End",
|
||||||
|
X = 5000,
|
||||||
|
Y = 404,
|
||||||
|
Width = 264,
|
||||||
|
Height = 132,
|
||||||
|
};
|
||||||
|
|
||||||
|
var failureArrival = new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/20",
|
||||||
|
SourceNodeId = sourceFailure.Id,
|
||||||
|
TargetNodeId = target.Id,
|
||||||
|
Label = "on failure / timeout",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 3408, Y = 164 },
|
||||||
|
EndPoint = new ElkPoint { X = 5000, Y = 438 },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 3408, Y = 64 },
|
||||||
|
new ElkPoint { X = 4620, Y = 64 },
|
||||||
|
new ElkPoint { X = 4620, Y = 438 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var defaultArrival = new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "edge/23",
|
||||||
|
SourceNodeId = sourceDefault.Id,
|
||||||
|
TargetNodeId = target.Id,
|
||||||
|
Label = "default",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new ElkPoint { X = 3768, Y = 400 },
|
||||||
|
EndPoint = new ElkPoint { X = 5000, Y = 472 },
|
||||||
|
BendPoints =
|
||||||
|
[
|
||||||
|
new ElkPoint { X = 3768, Y = 100 },
|
||||||
|
new ElkPoint { X = 4548, Y = 100 },
|
||||||
|
new ElkPoint { X = 4548, Y = 472 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY)
|
||||||
|
{
|
||||||
|
var path = ExtractPath(edge);
|
||||||
|
var bestLength = double.NegativeInfinity;
|
||||||
|
var bestY = double.NaN;
|
||||||
|
for (var i = 0; i < path.Count - 1; i++)
|
||||||
|
{
|
||||||
|
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|
||||||
|
|| path[i].Y >= graphMinY - 8d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = Math.Abs(path[i + 1].X - path[i].X);
|
||||||
|
if (length <= bestLength)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestLength = length;
|
||||||
|
bestY = path[i].Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
double.IsNaN(bestY).Should().BeFalse();
|
||||||
|
return bestY;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes = new[] { sourceFailure, sourceDefault, target };
|
||||||
|
var edges = new[] { failureArrival, defaultArrival };
|
||||||
|
var graphMinY = nodes.Min(node => node.Y);
|
||||||
|
Math.Abs(FindAboveGraphLaneY(failureArrival, graphMinY) - FindAboveGraphLaneY(defaultArrival, graphMinY))
|
||||||
|
.Should()
|
||||||
|
.BeGreaterThan(1d);
|
||||||
|
|
||||||
|
var repaired = ElkEdgePostProcessor.DistributeEndTerminalLeftFaceTrunks(edges, nodes, 53d);
|
||||||
|
|
||||||
|
var repairedFailure = repaired.Single(edge => edge.Id == "edge/20");
|
||||||
|
var repairedDefault = repaired.Single(edge => edge.Id == "edge/23");
|
||||||
|
var repairedFailurePath = ExtractPath(repairedFailure);
|
||||||
|
var repairedDefaultPath = ExtractPath(repairedDefault);
|
||||||
|
|
||||||
|
Math.Abs(FindAboveGraphLaneY(repairedFailure, graphMinY) - FindAboveGraphLaneY(repairedDefault, graphMinY))
|
||||||
|
.Should()
|
||||||
|
.BeLessThanOrEqualTo(1d);
|
||||||
|
ElkEdgeRoutingGeometry.ResolveBoundarySide(repairedFailurePath[^1], target).Should().Be("left");
|
||||||
|
ElkEdgeRoutingGeometry.ResolveBoundarySide(repairedDefaultPath[^1], target).Should().Be("left");
|
||||||
|
ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(repairedFailurePath[^1], repairedFailurePath[^2], target).Should().Be("left");
|
||||||
|
ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(repairedDefaultPath[^1], repairedDefaultPath[^2], target).Should().Be("left");
|
||||||
|
ElkEdgeRouterHighway.DetectRemainingBrokenHighways(repaired, nodes)
|
||||||
|
.Any(diagnostic => string.Equals(diagnostic.TargetNodeId, target.Id, StringComparison.Ordinal) && diagnostic.WasBroken)
|
||||||
|
.Should()
|
||||||
|
.BeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|
||||||
@@ -9,6 +12,124 @@ namespace StellaOps.Workflow.Renderer.Tests;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class WorkflowRenderSvgRendererTests
|
public class WorkflowRenderSvgRendererTests
|
||||||
{
|
{
|
||||||
|
[Test]
|
||||||
|
public void Render_WhenLegendContentIsSparse_ShouldSizeLegendToContent()
|
||||||
|
{
|
||||||
|
var renderer = new WorkflowRenderSvgRenderer();
|
||||||
|
var layout = new WorkflowRenderLayoutResult
|
||||||
|
{
|
||||||
|
GraphId = "legend-sparse",
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
new WorkflowRenderPositionedNode
|
||||||
|
{
|
||||||
|
Id = "start",
|
||||||
|
Label = "Start",
|
||||||
|
Kind = "Start",
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = 128,
|
||||||
|
Height = 64,
|
||||||
|
},
|
||||||
|
new WorkflowRenderPositionedNode
|
||||||
|
{
|
||||||
|
Id = "end",
|
||||||
|
Label = "End",
|
||||||
|
Kind = "End",
|
||||||
|
X = 220,
|
||||||
|
Y = 0,
|
||||||
|
Width = 128,
|
||||||
|
Height = 64,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
new WorkflowRenderRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "e1",
|
||||||
|
SourceNodeId = "start",
|
||||||
|
TargetNodeId = "end",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new WorkflowRenderEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new WorkflowRenderPoint { X = 128, Y = 32 },
|
||||||
|
EndPoint = new WorkflowRenderPoint { X = 220, Y = 32 },
|
||||||
|
BendPoints = [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var document = renderer.Render(layout, "LegendCompact");
|
||||||
|
var match = Regex.Match(
|
||||||
|
document.Svg,
|
||||||
|
"<rect x=\"24\" y=\"34\" rx=\"14\" ry=\"14\" width=\"[^\"]+\" height=\"([^\"]+)\"");
|
||||||
|
|
||||||
|
match.Success.Should().BeTrue();
|
||||||
|
var legendHeight = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||||
|
legendHeight.Should().BeLessThan(160d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Render_WhenEdgeLabelIsLong_ShouldWrapIntoMultipleBadgeLines()
|
||||||
|
{
|
||||||
|
var renderer = new WorkflowRenderSvgRenderer();
|
||||||
|
var layout = new WorkflowRenderLayoutResult
|
||||||
|
{
|
||||||
|
GraphId = "label-wrap",
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
new WorkflowRenderPositionedNode
|
||||||
|
{
|
||||||
|
Id = "left",
|
||||||
|
Label = "Left",
|
||||||
|
Kind = "TransportCall",
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = 196,
|
||||||
|
Height = 84,
|
||||||
|
},
|
||||||
|
new WorkflowRenderPositionedNode
|
||||||
|
{
|
||||||
|
Id = "right",
|
||||||
|
Label = "Right",
|
||||||
|
Kind = "Decision",
|
||||||
|
X = 320,
|
||||||
|
Y = 0,
|
||||||
|
Width = 144,
|
||||||
|
Height = 96,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Edges =
|
||||||
|
[
|
||||||
|
new WorkflowRenderRoutedEdge
|
||||||
|
{
|
||||||
|
Id = "e1",
|
||||||
|
SourceNodeId = "left",
|
||||||
|
TargetNodeId = "right",
|
||||||
|
Label = "when payload.amount exceeds approval threshold",
|
||||||
|
Sections =
|
||||||
|
[
|
||||||
|
new WorkflowRenderEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = new WorkflowRenderPoint { X = 196, Y = 42 },
|
||||||
|
EndPoint = new WorkflowRenderPoint { X = 320, Y = 48 },
|
||||||
|
BendPoints = [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var document = renderer.Render(layout, "EdgeLabelWrap");
|
||||||
|
|
||||||
|
document.Svg.Should().Contain(">when payload.amount exceeds<");
|
||||||
|
document.Svg.Should().Contain(">approval threshold<");
|
||||||
|
document.Svg.Should().NotContain(">when payload.amount exceeds approval threshold<");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Render_WhenTaskGatewayAndConditionsExist_ShouldEmitBoxesDiamondsLegendAndStyledBranches()
|
public void Render_WhenTaskGatewayAndConditionsExist_ShouldEmitBoxesDiamondsLegendAndStyledBranches()
|
||||||
{
|
{
|
||||||
@@ -154,7 +275,8 @@ public class WorkflowRenderSvgRendererTests
|
|||||||
document.Svg.Should().Contain("markerWidth=\"5\"");
|
document.Svg.Should().Contain("markerWidth=\"5\"");
|
||||||
document.Svg.Should().Contain("stroke-dasharray=\"2.2 4.8\"");
|
document.Svg.Should().Contain("stroke-dasharray=\"2.2 4.8\"");
|
||||||
document.Svg.Should().Contain("fill-opacity=\"0.54\"");
|
document.Svg.Should().Contain("fill-opacity=\"0.54\"");
|
||||||
document.Svg.Should().Contain("when payload.answer == "approve"");
|
document.Svg.Should().Contain(">when payload.answer ==<");
|
||||||
|
document.Svg.Should().Contain(">"approve"<");
|
||||||
document.Svg.Should().Contain("stroke=\"#15803d\"");
|
document.Svg.Should().Contain("stroke=\"#15803d\"");
|
||||||
document.Svg.Should().Contain("Call Pricing");
|
document.Svg.Should().Contain("Call Pricing");
|
||||||
document.Svg.Should().Contain(">Wait For Timeout<");
|
document.Svg.Should().Contain(">Wait For Timeout<");
|
||||||
@@ -255,7 +377,30 @@ public class WorkflowRenderSvgRendererTests
|
|||||||
var document = renderer.Render(layout, "BridgeGap");
|
var document = renderer.Render(layout, "BridgeGap");
|
||||||
|
|
||||||
document.Svg.Should().Contain("data-bridge-gap=\"true\"");
|
document.Svg.Should().Contain("data-bridge-gap=\"true\"");
|
||||||
document.Svg.Should().Contain("M 214.93,318");
|
Regex.IsMatch(document.Svg, "M 21[0-9](?:\\.\\d+)?,318 L 22[0-9](?:\\.\\d+)?,318")
|
||||||
document.Svg.Should().Contain("L 225.07,318");
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildRoundedEdgePath_WhenShortHorizontalJogPrecedesAShallowContinuation_ShouldPreserveTheHorizontalSpan()
|
||||||
|
{
|
||||||
|
var buildRoundedEdgePath = typeof(WorkflowRenderSvgRenderer)
|
||||||
|
.GetMethod("BuildRoundedEdgePath", BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
buildRoundedEdgePath.Should().NotBeNull();
|
||||||
|
|
||||||
|
WorkflowRenderPoint[] points =
|
||||||
|
[
|
||||||
|
new WorkflowRenderPoint { X = 0, Y = 0 },
|
||||||
|
new WorkflowRenderPoint { X = 24, Y = 0 },
|
||||||
|
new WorkflowRenderPoint { X = 164, Y = 2.8d },
|
||||||
|
];
|
||||||
|
|
||||||
|
var pathData = (string?)buildRoundedEdgePath!.Invoke(null, [points, 0d, 0d, 40d]);
|
||||||
|
|
||||||
|
pathData.Should().NotBeNullOrWhiteSpace();
|
||||||
|
pathData.Should().Contain("164,");
|
||||||
|
pathData.Should().NotContain("M 0,0 L 0,");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -171,7 +171,14 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
||||||
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
|
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
|
||||||
{
|
{
|
||||||
if (ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]))
|
var suppressForkBypassGatewayChecks = ElkEdgeRoutingScoring.ShouldSuppressForkBypassGatewaySourceExitChecks(
|
||||||
|
edge,
|
||||||
|
path,
|
||||||
|
edges,
|
||||||
|
nodesById,
|
||||||
|
sourceNode);
|
||||||
|
|
||||||
|
if (ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode))
|
||||||
{
|
{
|
||||||
sourceVertexExits++;
|
sourceVertexExits++;
|
||||||
focus.Add(edge.Id);
|
focus.Add(edge.Id);
|
||||||
@@ -189,7 +196,8 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
focus.Add(edge.Id);
|
focus.Add(edge.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (HasGatewaySourcePreferredFaceMismatchArtifact(
|
if (!suppressForkBypassGatewayChecks
|
||||||
|
&& HasGatewaySourcePreferredFaceMismatchArtifact(
|
||||||
path,
|
path,
|
||||||
sourceNode,
|
sourceNode,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -200,7 +208,8 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
focus.Add(edge.Id);
|
focus.Add(edge.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (HasGatewaySourceDominantAxisDetourArtifact(
|
if (!suppressForkBypassGatewayChecks
|
||||||
|
&& HasGatewaySourceDominantAxisDetourArtifact(
|
||||||
path,
|
path,
|
||||||
sourceNode,
|
sourceNode,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -211,7 +220,8 @@ internal static partial class ElkEdgeRouterIterative
|
|||||||
focus.Add(edge.Id);
|
focus.Add(edge.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
|
if (!suppressForkBypassGatewayChecks
|
||||||
|
&& ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
|
||||||
path,
|
path,
|
||||||
sourceNode,
|
sourceNode,
|
||||||
nodes,
|
nodes,
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ internal static partial class ElkEdgeRoutingScoring
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, firstSection.StartPoint))
|
if (!ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(
|
||||||
|
[firstSection.StartPoint, firstSection.BendPoints.FirstOrDefault() ?? firstSection.EndPoint],
|
||||||
|
sourceNode))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -130,14 +132,24 @@ internal static partial class ElkEdgeRoutingScoring
|
|||||||
sourceSideCounts,
|
sourceSideCounts,
|
||||||
boundarySlotSeverityByEdgeId);
|
boundarySlotSeverityByEdgeId);
|
||||||
var suppressSoftGatewayChecks = allowsSaturatedAlternateFace || isResolvedDiscreteSlotExit;
|
var suppressSoftGatewayChecks = allowsSaturatedAlternateFace || isResolvedDiscreteSlotExit;
|
||||||
|
var suppressForkBypassGatewayChecks = ShouldSuppressForkBypassGatewaySourceExitChecks(
|
||||||
|
edge,
|
||||||
|
path,
|
||||||
|
edges,
|
||||||
|
nodesById,
|
||||||
|
sourceNode);
|
||||||
|
var suppressStableOpportunity = suppressSoftGatewayChecks || suppressForkBypassGatewayChecks;
|
||||||
var hasViolation = HasGatewaySourceExitBacktracking(path)
|
var hasViolation = HasGatewaySourceExitBacktracking(path)
|
||||||
|| (!suppressSoftGatewayChecks
|
|| (!suppressSoftGatewayChecks
|
||||||
|
&& !suppressForkBypassGatewayChecks
|
||||||
&& HasGatewaySourceDominantAxisDetour(path, sourceNode))
|
&& HasGatewaySourceDominantAxisDetour(path, sourceNode))
|
||||||
|| (!suppressSoftGatewayChecks
|
|| (!suppressSoftGatewayChecks
|
||||||
&& ElkShapeBoundaries.IsNearGatewayVertex(sourceNode, path[0]))
|
&& !suppressForkBypassGatewayChecks
|
||||||
|
&& ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode))
|
||||||
|| (!suppressSoftGatewayChecks
|
|| (!suppressSoftGatewayChecks
|
||||||
|
&& !suppressForkBypassGatewayChecks
|
||||||
&& HasGatewaySourcePreferredFaceMismatch(path, sourceNode))
|
&& HasGatewaySourcePreferredFaceMismatch(path, sourceNode))
|
||||||
|| (!suppressSoftGatewayChecks
|
|| (!suppressStableOpportunity
|
||||||
&& currentBoundarySlotViolations == 0
|
&& currentBoundarySlotViolations == 0
|
||||||
&& currentBadBoundaryAngles == 0
|
&& currentBadBoundaryAngles == 0
|
||||||
&& HasGraphStableGatewaySourceOpportunity(
|
&& HasGraphStableGatewaySourceOpportunity(
|
||||||
@@ -163,6 +175,145 @@ internal static partial class ElkEdgeRoutingScoring
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool ShouldSuppressForkBypassGatewaySourceExitChecks(
|
||||||
|
ElkRoutedEdge edge,
|
||||||
|
IReadOnlyList<ElkPoint> path,
|
||||||
|
IReadOnlyCollection<ElkRoutedEdge> edges,
|
||||||
|
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||||
|
ElkPositionedNode sourceNode)
|
||||||
|
{
|
||||||
|
if (!string.Equals(sourceNode.Kind, "Fork", StringComparison.Ordinal)
|
||||||
|
|| path.Count < 2
|
||||||
|
|| !HasCleanOrthogonalGatewayDeparture(path, sourceNode)
|
||||||
|
|| HasGatewaySourceExitBacktracking(path)
|
||||||
|
|| ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(path, sourceNode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
|
||||||
|
|| !string.Equals(targetNode.Kind, "Join", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredSide = ResolvePrimaryGatewayDirection(sourceNode, targetNode);
|
||||||
|
if (desiredSide is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode);
|
||||||
|
if (string.Equals(currentSide, desiredSide, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsOrthogonalGatewaySideTransition(currentSide, desiredSide))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges.Any(peer =>
|
||||||
|
!string.Equals(peer.Id, edge.Id, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(peer.SourceNodeId, edge.SourceNodeId, StringComparison.Ordinal)
|
||||||
|
&& DoesPeerOwnForkPrimaryAxis(peer, nodesById, sourceNode, desiredSide));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DoesPeerOwnForkPrimaryAxis(
|
||||||
|
ElkRoutedEdge peer,
|
||||||
|
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||||
|
ElkPositionedNode sourceNode,
|
||||||
|
string desiredSide)
|
||||||
|
{
|
||||||
|
var peerPath = ExtractPath(peer);
|
||||||
|
if (peerPath.Count < 2
|
||||||
|
|| !HasCleanOrthogonalGatewayDeparture(peerPath, sourceNode)
|
||||||
|
|| HasGatewaySourceExitBacktracking(peerPath)
|
||||||
|
|| ElkEdgePostProcessor.HasProblematicGatewaySourceVertexExit(peerPath, sourceNode)
|
||||||
|
|| !string.Equals(
|
||||||
|
ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(peerPath[0], peerPath[1], sourceNode),
|
||||||
|
desiredSide,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodesById.TryGetValue(peer.TargetNodeId ?? string.Empty, out var peerTargetNode))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.Equals(peerTargetNode.Kind, "Join", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolvePrimaryGatewayDirection(
|
||||||
|
ElkPositionedNode sourceNode,
|
||||||
|
ElkPositionedNode targetNode)
|
||||||
|
{
|
||||||
|
var sourceCenterX = sourceNode.X + (sourceNode.Width / 2d);
|
||||||
|
var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d);
|
||||||
|
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
|
||||||
|
var targetCenterY = targetNode.Y + (targetNode.Height / 2d);
|
||||||
|
var dx = targetCenterX - sourceCenterX;
|
||||||
|
var dy = targetCenterY - sourceCenterY;
|
||||||
|
|
||||||
|
if (Math.Abs(dx) >= Math.Abs(dy) * 1.15d && Math.Sign(dx) != 0)
|
||||||
|
{
|
||||||
|
return dx > 0d ? "right" : "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.Abs(dy) >= Math.Abs(dx) * 1.15d && Math.Sign(dy) != 0)
|
||||||
|
{
|
||||||
|
return dy > 0d ? "bottom" : "top";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.Sign(dx) != 0)
|
||||||
|
{
|
||||||
|
return dx > 0d ? "right" : "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Sign(dy) == 0
|
||||||
|
? null
|
||||||
|
: (dy > 0d ? "bottom" : "top");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasCleanOrthogonalGatewayDeparture(
|
||||||
|
IReadOnlyList<ElkPoint> path,
|
||||||
|
ElkPositionedNode sourceNode)
|
||||||
|
{
|
||||||
|
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode) || path.Count < 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double tolerance = 0.5d;
|
||||||
|
var boundary = path[0];
|
||||||
|
var adjacent = path[1];
|
||||||
|
var side = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(boundary, adjacent, sourceNode);
|
||||||
|
return side switch
|
||||||
|
{
|
||||||
|
"left" => adjacent.X < boundary.X - tolerance && Math.Abs(adjacent.Y - boundary.Y) <= tolerance,
|
||||||
|
"right" => adjacent.X > boundary.X + tolerance && Math.Abs(adjacent.Y - boundary.Y) <= tolerance,
|
||||||
|
"top" => adjacent.Y < boundary.Y - tolerance && Math.Abs(adjacent.X - boundary.X) <= tolerance,
|
||||||
|
"bottom" => adjacent.Y > boundary.Y + tolerance && Math.Abs(adjacent.X - boundary.X) <= tolerance,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOrthogonalGatewaySideTransition(string? currentSide, string? desiredSide)
|
||||||
|
{
|
||||||
|
if (currentSide is not ("left" or "right" or "top" or "bottom")
|
||||||
|
|| desiredSide is not ("left" or "right" or "top" or "bottom"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIsHorizontal = currentSide is "left" or "right";
|
||||||
|
var desiredIsHorizontal = desiredSide is "left" or "right";
|
||||||
|
return currentIsHorizontal != desiredIsHorizontal;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsResolvedGatewaySourceSlotExit(
|
private static bool IsResolvedGatewaySourceSlotExit(
|
||||||
ElkRoutedEdge edge,
|
ElkRoutedEdge edge,
|
||||||
IReadOnlyList<ElkPoint> path,
|
IReadOnlyList<ElkPoint> path,
|
||||||
|
|||||||
428
src/__Libraries/StellaOps.ElkSharp/ElkTopCorridorOwnership.cs
Normal file
428
src/__Libraries/StellaOps.ElkSharp/ElkTopCorridorOwnership.cs
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
namespace StellaOps.ElkSharp;
|
||||||
|
|
||||||
|
internal static class ElkTopCorridorOwnership
|
||||||
|
{
|
||||||
|
private const double CoordinateTolerance = 0.5d;
|
||||||
|
|
||||||
|
internal static ElkRoutedEdge[] SpreadAboveGraphCorridorLanes(
|
||||||
|
ElkRoutedEdge[] edges,
|
||||||
|
ElkPositionedNode[] nodes,
|
||||||
|
double minLineClearance)
|
||||||
|
{
|
||||||
|
if (edges.Length < 2 || nodes.Length == 0)
|
||||||
|
{
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||||
|
var graphMinY = nodes.Min(node => node.Y);
|
||||||
|
var result = edges.ToArray();
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
var candidates = result
|
||||||
|
.Select((edge, index) => CreateCandidate(edge, index, nodesById, graphMinY))
|
||||||
|
.Where(candidate => candidate is not null)
|
||||||
|
.Select(candidate => candidate!.Value)
|
||||||
|
.OrderBy(candidate => candidate.MinX)
|
||||||
|
.ThenBy(candidate => candidate.MaxX)
|
||||||
|
.ToArray();
|
||||||
|
if (candidates.Length < 2)
|
||||||
|
{
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cluster in BuildOverlapClusters(candidates))
|
||||||
|
{
|
||||||
|
if (cluster.Length < 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baselineMetrics = MeasureClusterMetrics(cluster, result, nodes, nodesById, graphMinY);
|
||||||
|
var candidateEdges = RewriteCluster(result, cluster, graphMinY, minLineClearance);
|
||||||
|
if (ReferenceEquals(candidateEdges, result))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateMetrics = MeasureClusterMetrics(cluster, candidateEdges, nodes, nodesById, graphMinY);
|
||||||
|
if (!candidateMetrics.IsBetterThan(baselineMetrics))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = candidateEdges;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? result : edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveLaneStep(
|
||||||
|
AboveGraphCorridorCandidate? previousAssignedLane,
|
||||||
|
AboveGraphCorridorCandidate currentLane,
|
||||||
|
double minLineClearance)
|
||||||
|
{
|
||||||
|
var compactGap = Math.Max(14d, (minLineClearance * 0.55d) + 4d);
|
||||||
|
if (previousAssignedLane is null)
|
||||||
|
{
|
||||||
|
return compactGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousAssignedLane.Value.Priority == currentLane.Priority
|
||||||
|
? compactGap
|
||||||
|
: Math.Max(compactGap + 4d, Math.Min(26d, minLineClearance + 2d));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<AboveGraphCorridorCandidate[]> BuildOverlapClusters(
|
||||||
|
IReadOnlyList<AboveGraphCorridorCandidate> candidates)
|
||||||
|
{
|
||||||
|
var clusters = new List<AboveGraphCorridorCandidate[]>();
|
||||||
|
var current = new List<AboveGraphCorridorCandidate>();
|
||||||
|
var currentMaxX = double.NegativeInfinity;
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (current.Count == 0
|
||||||
|
|| candidate.MinX <= currentMaxX + CoordinateTolerance)
|
||||||
|
{
|
||||||
|
current.Add(candidate);
|
||||||
|
currentMaxX = Math.Max(currentMaxX, candidate.MaxX);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
clusters.Add(current.ToArray());
|
||||||
|
current =
|
||||||
|
[
|
||||||
|
candidate,
|
||||||
|
];
|
||||||
|
currentMaxX = candidate.MaxX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Count > 0)
|
||||||
|
{
|
||||||
|
clusters.Add(current.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AboveGraphCorridorCandidate? CreateCandidate(
|
||||||
|
ElkRoutedEdge edge,
|
||||||
|
int index,
|
||||||
|
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||||
|
double graphMinY)
|
||||||
|
{
|
||||||
|
var path = ExtractPath(edge);
|
||||||
|
if (path.Count < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
AboveGraphCorridorCandidate? best = null;
|
||||||
|
for (var i = 0; i < path.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var start = path[i];
|
||||||
|
var end = path[i + 1];
|
||||||
|
if (Math.Abs(start.Y - end.Y) > CoordinateTolerance)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var corridorY = (start.Y + end.Y) / 2d;
|
||||||
|
if (corridorY >= graphMinY - 8d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = Math.Abs(end.X - start.X);
|
||||||
|
if (length <= 1d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var priority = ResolvePriority(edge, nodesById);
|
||||||
|
var ownershipKey = ResolveOwnershipKey(edge, nodesById);
|
||||||
|
var candidate = new AboveGraphCorridorCandidate(
|
||||||
|
index,
|
||||||
|
edge.Id,
|
||||||
|
corridorY,
|
||||||
|
Math.Min(start.X, end.X),
|
||||||
|
Math.Max(start.X, end.X),
|
||||||
|
length,
|
||||||
|
priority,
|
||||||
|
ownershipKey);
|
||||||
|
if (best is null || candidate.Length > best.Value.Length)
|
||||||
|
{
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolvePriority(
|
||||||
|
ElkRoutedEdge edge,
|
||||||
|
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
|
||||||
|
{
|
||||||
|
if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(edge.TargetNodeId)
|
||||||
|
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|
||||||
|
&& string.Equals(targetNode.Kind, "End", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveOwnershipKey(
|
||||||
|
ElkRoutedEdge edge,
|
||||||
|
IReadOnlyDictionary<string, ElkPositionedNode> nodesById)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(edge.TargetNodeId)
|
||||||
|
&& nodesById.TryGetValue(edge.TargetNodeId, out var targetNode)
|
||||||
|
&& string.Equals(targetNode.Kind, "End", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return $"end:{targetNode.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))
|
||||||
|
{
|
||||||
|
return $"repeat:{edge.TargetNodeId ?? edge.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return edge.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElkRoutedEdge[] RewriteCluster(
|
||||||
|
IReadOnlyList<ElkRoutedEdge> edges,
|
||||||
|
IReadOnlyList<AboveGraphCorridorCandidate> cluster,
|
||||||
|
double graphMinY,
|
||||||
|
double minLineClearance)
|
||||||
|
{
|
||||||
|
var groups = cluster
|
||||||
|
.GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var orderedMembers = group
|
||||||
|
.OrderBy(member => member.Index)
|
||||||
|
.ToArray();
|
||||||
|
var representative = orderedMembers
|
||||||
|
.OrderByDescending(member => member.Length)
|
||||||
|
.ThenByDescending(member => member.CorridorY)
|
||||||
|
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||||
|
.First();
|
||||||
|
return new AboveGraphOwnershipGroup(
|
||||||
|
OwnershipKey: group.Key,
|
||||||
|
Members: orderedMembers,
|
||||||
|
Representative: representative,
|
||||||
|
Priority: orderedMembers.Min(member => member.Priority),
|
||||||
|
PreferredLaneY: orderedMembers.Max(member => member.CorridorY),
|
||||||
|
Span: orderedMembers.Max(member => member.MaxX) - orderedMembers.Min(member => member.MinX));
|
||||||
|
})
|
||||||
|
.OrderBy(group => group.Priority)
|
||||||
|
.ThenByDescending(group => group.PreferredLaneY)
|
||||||
|
.ThenByDescending(group => group.Span)
|
||||||
|
.ThenBy(group => group.OwnershipKey, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (groups.Length < 2 && groups[0].Members.All(member => Math.Abs(member.CorridorY - groups[0].PreferredLaneY) <= CoordinateTolerance))
|
||||||
|
{
|
||||||
|
return edges as ElkRoutedEdge[] ?? edges.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = edges.ToArray();
|
||||||
|
var anchorY = groups.Max(group => group.PreferredLaneY);
|
||||||
|
AboveGraphCorridorCandidate? previousAssignedLane = null;
|
||||||
|
var assignedY = anchorY;
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (previousAssignedLane is not null)
|
||||||
|
{
|
||||||
|
assignedY -= ResolveLaneStep(previousAssignedLane.Value, group.Representative, minLineClearance);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var member in group.Members)
|
||||||
|
{
|
||||||
|
var rewritten = RewriteCorridorLane(result[member.Index], member.CorridorY, assignedY, graphMinY);
|
||||||
|
if (!ReferenceEquals(rewritten, result[member.Index]))
|
||||||
|
{
|
||||||
|
result[member.Index] = rewritten;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousAssignedLane = group.Representative;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? result : (edges as ElkRoutedEdge[] ?? edges.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CorridorClusterMetrics MeasureClusterMetrics(
|
||||||
|
IReadOnlyList<AboveGraphCorridorCandidate> cluster,
|
||||||
|
IReadOnlyList<ElkRoutedEdge> edges,
|
||||||
|
ElkPositionedNode[] nodes,
|
||||||
|
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||||
|
double graphMinY)
|
||||||
|
{
|
||||||
|
var liveCandidates = cluster
|
||||||
|
.Select(member => CreateCandidate(edges[member.Index], member.Index, nodesById, graphMinY))
|
||||||
|
.Where(candidate => candidate is not null)
|
||||||
|
.Select(candidate => candidate!.Value)
|
||||||
|
.ToArray();
|
||||||
|
var ownershipSplits = liveCandidates
|
||||||
|
.GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal)
|
||||||
|
.Count(group =>
|
||||||
|
group.Select(candidate => Math.Round(candidate.CorridorY, 1))
|
||||||
|
.Distinct()
|
||||||
|
.Count() > 1);
|
||||||
|
var orderedOwnershipLanes = liveCandidates
|
||||||
|
.GroupBy(candidate => candidate.OwnershipKey, StringComparer.Ordinal)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Priority = group.Min(candidate => candidate.Priority),
|
||||||
|
LaneY = group.Max(candidate => candidate.CorridorY),
|
||||||
|
})
|
||||||
|
.OrderBy(entry => entry.Priority)
|
||||||
|
.ThenByDescending(entry => entry.LaneY)
|
||||||
|
.ToArray();
|
||||||
|
var priorityInversions = 0;
|
||||||
|
for (var i = 1; i < orderedOwnershipLanes.Length; i++)
|
||||||
|
{
|
||||||
|
if (orderedOwnershipLanes[i - 1].Priority == orderedOwnershipLanes[i].Priority)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderedOwnershipLanes[i - 1].LaneY + CoordinateTolerance < orderedOwnershipLanes[i].LaneY)
|
||||||
|
{
|
||||||
|
priorityInversions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var clusterEdgeIds = cluster
|
||||||
|
.Select(candidate => candidate.EdgeId)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var brokenTopHighways = ElkEdgeRouterHighway
|
||||||
|
.DetectRemainingBrokenHighways(edges as ElkRoutedEdge[] ?? edges.ToArray(), nodes)
|
||||||
|
.Count(diagnostic =>
|
||||||
|
diagnostic.WasBroken
|
||||||
|
&& string.Equals(diagnostic.SharedAxis, "top", StringComparison.Ordinal)
|
||||||
|
&& diagnostic.EdgeIds.Any(clusterEdgeIds.Contains));
|
||||||
|
var laneSpan = liveCandidates.Length == 0
|
||||||
|
? 0d
|
||||||
|
: liveCandidates.Max(candidate => candidate.CorridorY) - liveCandidates.Min(candidate => candidate.CorridorY);
|
||||||
|
|
||||||
|
return new CorridorClusterMetrics(
|
||||||
|
BrokenTopHighways: brokenTopHighways,
|
||||||
|
OwnershipSplits: ownershipSplits,
|
||||||
|
PriorityInversions: priorityInversions,
|
||||||
|
LaneSpan: laneSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElkRoutedEdge RewriteCorridorLane(
|
||||||
|
ElkRoutedEdge edge,
|
||||||
|
double currentY,
|
||||||
|
double assignedY,
|
||||||
|
double graphMinY)
|
||||||
|
{
|
||||||
|
if (Math.Abs(currentY - assignedY) <= CoordinateTolerance)
|
||||||
|
{
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShouldShift(ElkPoint point) =>
|
||||||
|
Math.Abs(point.Y - currentY) <= CoordinateTolerance
|
||||||
|
&& point.Y < graphMinY - 8d;
|
||||||
|
|
||||||
|
ElkPoint Shift(ElkPoint point) => ShouldShift(point)
|
||||||
|
? new ElkPoint { X = point.X, Y = assignedY }
|
||||||
|
: point;
|
||||||
|
|
||||||
|
return new ElkRoutedEdge
|
||||||
|
{
|
||||||
|
Id = edge.Id,
|
||||||
|
SourceNodeId = edge.SourceNodeId,
|
||||||
|
TargetNodeId = edge.TargetNodeId,
|
||||||
|
SourcePortId = edge.SourcePortId,
|
||||||
|
TargetPortId = edge.TargetPortId,
|
||||||
|
Kind = edge.Kind,
|
||||||
|
Label = edge.Label,
|
||||||
|
Sections = edge.Sections
|
||||||
|
.Select(section => new ElkEdgeSection
|
||||||
|
{
|
||||||
|
StartPoint = Shift(section.StartPoint),
|
||||||
|
EndPoint = Shift(section.EndPoint),
|
||||||
|
BendPoints = section.BendPoints.Select(Shift).ToArray(),
|
||||||
|
})
|
||||||
|
.ToArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ElkPoint> ExtractPath(ElkRoutedEdge edge)
|
||||||
|
{
|
||||||
|
var path = new List<ElkPoint>();
|
||||||
|
foreach (var section in edge.Sections)
|
||||||
|
{
|
||||||
|
if (path.Count == 0)
|
||||||
|
{
|
||||||
|
path.Add(section.StartPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
path.AddRange(section.BendPoints);
|
||||||
|
path.Add(section.EndPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct AboveGraphCorridorCandidate(
|
||||||
|
int Index,
|
||||||
|
string EdgeId,
|
||||||
|
double CorridorY,
|
||||||
|
double MinX,
|
||||||
|
double MaxX,
|
||||||
|
double Length,
|
||||||
|
int Priority,
|
||||||
|
string OwnershipKey);
|
||||||
|
|
||||||
|
private readonly record struct AboveGraphOwnershipGroup(
|
||||||
|
string OwnershipKey,
|
||||||
|
AboveGraphCorridorCandidate[] Members,
|
||||||
|
AboveGraphCorridorCandidate Representative,
|
||||||
|
int Priority,
|
||||||
|
double PreferredLaneY,
|
||||||
|
double Span);
|
||||||
|
|
||||||
|
private readonly record struct CorridorClusterMetrics(
|
||||||
|
int BrokenTopHighways,
|
||||||
|
int OwnershipSplits,
|
||||||
|
int PriorityInversions,
|
||||||
|
double LaneSpan)
|
||||||
|
{
|
||||||
|
internal bool IsBetterThan(CorridorClusterMetrics baseline)
|
||||||
|
{
|
||||||
|
if (BrokenTopHighways != baseline.BrokenTopHighways)
|
||||||
|
{
|
||||||
|
return BrokenTopHighways < baseline.BrokenTopHighways;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OwnershipSplits != baseline.OwnershipSplits)
|
||||||
|
{
|
||||||
|
return OwnershipSplits < baseline.OwnershipSplits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PriorityInversions != baseline.PriorityInversions)
|
||||||
|
{
|
||||||
|
return PriorityInversions < baseline.PriorityInversions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LaneSpan + CoordinateTolerance < baseline.LaneSpan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user