Improve rendering

This commit is contained in:
master
2026-03-21 01:03:20 +02:00
parent d2e542f77e
commit eb27a69778
28 changed files with 9802 additions and 4490 deletions

View File

@@ -188,7 +188,7 @@ public sealed class WorkflowRenderSvgRenderer
],
trunkStyle,
collectorStrokeWidth,
highway.IsBackward ? null : trunkStyle.MarkerId,
trunkStyle.MarkerId,
collectorOpacity,
highway.GroupId,
IsCollector: true));
@@ -251,7 +251,29 @@ public sealed class WorkflowRenderSvgRenderer
}
var renderedPath = renderedPaths[pathIndex];
var pathData = BuildRoundedEdgePath(renderedPath.Points, offsetX, offsetY, renderedPath.IsCollector ? 0d : 12d);
var renderPoints = renderedPath.Points;
if (!string.IsNullOrWhiteSpace(renderedPath.MarkerId) && renderPoints.Count >= 2)
{
var arrowLen = 5d * renderedPath.StrokeWidth;
var last = renderPoints[^1];
var prev = renderPoints[^2];
var dx = last.X - prev.X;
var dy = last.Y - prev.Y;
var segLen = Math.Sqrt(dx * dx + dy * dy);
if (segLen > arrowLen * 0.5d)
{
var pullback = Math.Min(arrowLen * 0.7d, segLen * 0.6d);
var shortened = renderPoints.ToList();
shortened[^1] = new WorkflowRenderPoint
{
X = last.X - (dx / segLen * pullback),
Y = last.Y - (dy / segLen * pullback),
};
renderPoints = shortened;
}
}
var pathData = BuildRoundedEdgePath(renderPoints, offsetX, offsetY, renderedPath.IsCollector ? 0d : 12d);
var markerAttribute = string.IsNullOrWhiteSpace(renderedPath.MarkerId)
? string.Empty
: $" marker-end=\"{renderedPath.MarkerId}\"";
@@ -491,7 +513,7 @@ public sealed class WorkflowRenderSvgRenderer
<text x="{Format(placement.CenterX)}" y="{Format(placement.Top + 14)}"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="10.7"
font-size="11.5"
font-weight="700"
fill="{placement.Style.LabelText}">{Encode(placement.Label)}</text>
""");
@@ -869,10 +891,11 @@ public sealed class WorkflowRenderSvgRenderer
double anchorY;
if (isErrorLabel && points.Count >= 2)
{
var sourcePoint = points[0];
var secondPoint = points[Math.Min(1, points.Count - 1)];
anchorX = (sourcePoint.X * 0.6d + secondPoint.X * 0.4d) + offsetX;
anchorY = (sourcePoint.Y * 0.6d + secondPoint.Y * 0.4d) + offsetY;
var longestSeg = ResolveLabelAnchorSegment(points);
var segMidX = (longestSeg.Start.X + longestSeg.End.X) / 2d;
var segMidY = (longestSeg.Start.Y + longestSeg.End.Y) / 2d;
anchorX = segMidX + offsetX;
anchorY = segMidY + offsetY;
}
else
{
@@ -1256,9 +1279,10 @@ public sealed class WorkflowRenderSvgRenderer
group.First().FamilyKey,
isBackward);
})
.Where(candidate => candidate.IsBackward || !string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase))
.Where(candidate => candidate.IsBackward
|| string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase))
.Where(candidate =>
candidate.EdgeIds.Count >= (string.Equals(candidate.TargetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) ? 2 : 3))
candidate.EdgeIds.Count >= (candidate.IsBackward ? 3 : 2))
.ToArray();
foreach (var targetDirectionGroup in candidateGroups
@@ -1291,20 +1315,45 @@ public sealed class WorkflowRenderSvgRenderer
.FirstOrDefault();
collectorY = preferredCollectorY
?? (targetNode.Y - 42d - ((orderedGroups.Length - 1) * 4d));
var requiredOverlapCount = candidate.EdgeIds.Count <= 2 ? candidate.EdgeIds.Count : (candidate.EdgeIds.Count / 2) + 1;
if (!TryResolveHorizontalOverlapInterval(candidate.Edges, collectorY, requiredOverlapCount, out var sharedMinX, out var sharedMaxX))
// Ensure collectorY doesn't pass through any node
var collectorMinX = candidate.Edges.Min(edge => edge.Sections.First().EndPoint.X);
var collectorMaxX = candidate.Edges.Max(edge => edge.Sections.First().StartPoint.X);
foreach (var obstacleNode in layout.Nodes)
{
collectorX = targetNode.X + (targetNode.Width / 2d)
+ ResolveCenteredOffset(bandIndex, orderedGroups.Length, Math.Min(18d, targetNode.Width / 4d));
targetX = collectorX;
if (string.Equals(obstacleNode.Id, candidate.TargetId, StringComparison.Ordinal))
{
continue;
}
if (obstacleNode.X + obstacleNode.Width > collectorMinX
&& obstacleNode.X < collectorMaxX
&& collectorY > obstacleNode.Y - 24d
&& collectorY < obstacleNode.Y + obstacleNode.Height + 24d)
{
collectorY = obstacleNode.Y - 36d;
}
}
var requiredOverlapCount = candidate.EdgeIds.Count <= 2 ? candidate.EdgeIds.Count : (candidate.EdgeIds.Count / 2) + 1;
if (!TryResolveHorizontalOverlapInterval(candidate.Edges, collectorY, requiredOverlapCount, out var sharedMinX, out var sharedMaxX)
|| Math.Abs(sharedMaxX - sharedMinX) < 40d)
{
collectorX = targetNode.X + (targetNode.Width / 2d) + 24d;
targetX = targetNode.X + (targetNode.Width / 2d);
}
else
{
collectorX = sharedMaxX;
targetX = sharedMinX;
targetX = Math.Min(sharedMinX, targetNode.X + (targetNode.Width / 2d));
}
targetY = targetNode.Y;
if (Math.Abs(collectorY - targetY) < 28d)
{
collectorY = targetY - 32d;
}
spreadPerEdge = 0d;
groups[groupId] = new HighwayGroup(

View File

@@ -8,39 +8,39 @@ using StellaOps.Workflow.Renderer.Svg;
namespace StellaOps.Workflow.Renderer.Tests;
[TestFixture]
public class AssistantPrintInsisDocumentsRenderingTests
public class DocumentProcessingWorkflowRenderingTests
{
private static WorkflowRenderGraph BuildAssistantPrintInsisDocumentsGraph()
private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph()
{
return new WorkflowRenderGraph
{
Id = "AssistantPrintInsisDocuments:1.0.0",
Id = "DocumentProcessingWorkflow:1.0.0",
Nodes =
[
new WorkflowRenderNode { Id = "start", Label = "Start", Kind = "Start", Width = 264, Height = 132 },
new WorkflowRenderNode { Id = "start/1", Label = "Assign Business Reference", Kind = "BusinessReference", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/split", Label = "Spin off async process", Kind = "Fork", Width = 176, Height = 124 },
new WorkflowRenderNode { Id = "start/2/join", Label = "Spin off async process Join", Kind = "Join", Width = 176, Height = 124 },
new WorkflowRenderNode { Id = "start/3", Label = "Load Notification Parameters", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/4/batched", Label = "Setting:\nnotificationParameters\nskipSystemNotification\ntoEmailsCount\nnotificationHasBody\nnotificationHasTitle", Kind = "SetState", Width = 224, Height = 104 },
new WorkflowRenderNode { Id = "start/9", Label = "Has Notification Content", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/9/true/1", Label = "Send Private Note", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/9/true/1/true/1", Label = "Send Private Note", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/9/true/1/true/1/handled/1", Label = "Set notificationPrivateNoteFailed", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/9/true/2", Label = "Has Notification Emails", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/9/true/2/true/1", Label = "Send Notification Email", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/9/true/2/true/1/handled/1", Label = "Set notificationEmailFailed", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/1", Label = "Initialize Context", Kind = "BusinessReference", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/split", Label = "Parallel Execution", Kind = "Fork", Width = 176, Height = 124 },
new WorkflowRenderNode { Id = "start/2/join", Label = "Parallel Execution Join", Kind = "Join", Width = 176, Height = 124 },
new WorkflowRenderNode { Id = "start/3", Label = "Load Configuration", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/4/batched", Label = "Setting:\nconfigParameters\nskipInternalNotification\nrecipientCount\nconfigHasBody\nconfigHasTitle", Kind = "SetState", Width = 224, Height = 104 },
new WorkflowRenderNode { Id = "start/9", Label = "Evaluate Conditions", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/9/true/1", Label = "Internal Notification", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/9/true/1/true/1", Label = "Internal Notification", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/9/true/1/true/1/handled/1", Label = "Set internalNotificationFailed", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/9/true/2", Label = "Has Recipients", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/9/true/2/true/1", Label = "Email Dispatch", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/9/true/2/true/1/handled/1", Label = "Set emailDispatchFailed", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "end", Label = "End", Kind = "End", Width = 264, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1", Label = "Generate Documents", Kind = "Repeat", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/1/batched", Label = "Setting:\nprintTimedOut\nprintGenerateFailed\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4", Label = "Print Batch Documents", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1", Label = "Attempt Again", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "Wait 5m", Kind = "Timer", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/2", Label = "Set printGenerateFailed", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5", Label = "Print Batch Returned Result", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1", Label = "Print Batch Succeeded", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "Setting:\npolicyNo\nfiles\ndocsCount\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set printTimedOut", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1", Label = "Process Batch", Kind = "Repeat", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/1/batched", Label = "Setting:\nbatchTimedOut\nbatchGenerateFailed\nhasMissingItems", Kind = "SetState", Width = 224, Height = 104 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4", Label = "Execute Batch", Kind = "TransportCall", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1", Label = "Retry Decision", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "Cooldown Timer", Kind = "Timer", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/2", Label = "Set batchGenerateFailed", Kind = "SetState", Width = 208, Height = 88 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5", Label = "Check Result", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1", Label = "Validate Success", Kind = "Decision", Width = 188, Height = 132 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "Setting:\nitemId\nfiles\nitemsCount\nhasMissingItems", Kind = "SetState", Width = 224, Height = 104 },
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set batchTimedOut", Kind = "SetState", Width = 208, Height = 88 },
],
Edges =
[
@@ -85,9 +85,9 @@ public class AssistantPrintInsisDocumentsRenderingTests
}
[Test]
public async Task AssistantPrintInsisDocuments_WhenLayoutOnly_ShouldProduceFinitePositions()
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
{
var graph = BuildAssistantPrintInsisDocumentsGraph();
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
@@ -102,9 +102,9 @@ public class AssistantPrintInsisDocumentsRenderingTests
[Test]
[Category("RenderingArtifacts")]
public async Task AssistantPrintInsisDocuments_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
{
var graph = BuildAssistantPrintInsisDocumentsGraph();
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
@@ -113,11 +113,11 @@ public class AssistantPrintInsisDocumentsRenderingTests
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "AssistantPrintInsisDocuments [ElkSharp]");
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
var outputDir = Path.Combine(
Path.GetDirectoryName(typeof(AssistantPrintInsisDocumentsRenderingTests).Assembly.Location)!,
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "AssistantPrintInsisDocuments");
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
Directory.CreateDirectory(outputDir);
var svgPath = Path.Combine(outputDir, "elksharp.svg");