Add four late-stage post-processing steps that run after the iterative optimizer to improve edge readability without affecting hard routing correctness: - SpreadOuterCorridors: enforce min 32px gap between adjacent above-graph corridors and push End-bound corridors below all repeat-return corridors into their own visual tier (Y=-235 vs Y=-203/-139/-36) - CollapseOrthogonalBacktracks: detect and remove U-turn loops where edges go right then backtrack left then right again (edge/17 fixed from 7-segment loop to clean 3-segment forward path) - ExtendShortApproachSegments: extend short final approach segments to half the average node width (~101px) so arrowheads have clear directional runs into target nodes (11 edges improved, worst case 8px to 71px) - ReduceLineNodeProximity: push edge segments away from non-terminal nodes when within min-clearance (line-node proximity reduced to 2 violations) Final metrics on document processing render: - Edge crossings: 24 → 21 (-12.5%) - Label proximity: 6 → 0 (eliminated) - Line-node proximity: reduced to 2 - All 7 hard defect classes: zero (maintained) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
9.2 KiB
C#
285 lines
9.2 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
/// <summary>
|
|
/// Extends short final approach segments so the arrowhead has a clear
|
|
/// directional run into the target node. Shifts the penultimate vertical
|
|
/// bend point away from the target to create a longer horizontal approach.
|
|
/// Only modifies non-gateway rectangular target approaches.
|
|
/// </summary>
|
|
internal static ElkRoutedEdge[] ExtendShortApproachSegments(
|
|
ElkRoutedEdge[] edges,
|
|
ElkPositionedNode[] nodes)
|
|
{
|
|
if (edges.Length == 0 || nodes.Length == 0)
|
|
{
|
|
return edges;
|
|
}
|
|
|
|
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
|
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
|
var avgWidth = serviceNodes.Length > 0 ? serviceNodes.Average(node => node.Width) : 160d;
|
|
var desiredMinApproach = Math.Max(48d, avgWidth / 2d);
|
|
var graphMinY = nodes.Min(node => node.Y);
|
|
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
|
|
|
var result = edges.ToArray();
|
|
var changed = false;
|
|
|
|
for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++)
|
|
{
|
|
var edge = result[edgeIndex];
|
|
|
|
if (string.IsNullOrWhiteSpace(edge.TargetNodeId)
|
|
|| !nodesById.TryGetValue(edge.TargetNodeId!, out var targetNode)
|
|
|| HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = ExtractFullPath(edge);
|
|
if (path.Count < 3)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var extended = TryExtendApproach(path, targetNode, desiredMinApproach);
|
|
if (extended is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (HasNodeObstacleCrossing(extended, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var candidateEdge = BuildSingleSectionEdge(edge, extended);
|
|
var candidateEdges = result.ToArray();
|
|
candidateEdges[edgeIndex] = candidateEdge;
|
|
var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes);
|
|
var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes);
|
|
if (newShared > oldShared)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result[edgeIndex] = candidateEdge;
|
|
changed = true;
|
|
}
|
|
|
|
return changed ? result : edges;
|
|
}
|
|
|
|
private static List<ElkPoint>? TryExtendApproach(
|
|
IReadOnlyList<ElkPoint> path,
|
|
ElkPositionedNode targetNode,
|
|
double desiredMinApproach)
|
|
{
|
|
const double tolerance = 1d;
|
|
if (path.Count < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var endpoint = path[^1];
|
|
var penultimate = path[^2];
|
|
|
|
// Skip diagonal final segments (gateway tip approaches)
|
|
var dx = Math.Abs(endpoint.X - penultimate.X);
|
|
var dy = Math.Abs(endpoint.Y - penultimate.Y);
|
|
if (dx > 3d && dy > 3d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Left-face horizontal approach (most common in LTR layout)
|
|
if (Math.Abs(penultimate.Y - endpoint.Y) <= tolerance
|
|
&& endpoint.X > penultimate.X)
|
|
{
|
|
return TryExtendLeftFaceApproach(path, desiredMinApproach, tolerance);
|
|
}
|
|
|
|
// Top-face vertical approach
|
|
if (Math.Abs(penultimate.X - endpoint.X) <= tolerance
|
|
&& endpoint.Y > penultimate.Y)
|
|
{
|
|
return TryExtendTopFaceApproach(path, desiredMinApproach, tolerance);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static List<ElkPoint>? TryExtendLeftFaceApproach(
|
|
IReadOnlyList<ElkPoint> path,
|
|
double desiredMinApproach,
|
|
double tolerance)
|
|
{
|
|
var endpoint = path[^1];
|
|
var penultimate = path[^2];
|
|
var currentApproach = endpoint.X - penultimate.X;
|
|
|
|
if (currentApproach <= 0 || currentApproach >= desiredMinApproach - tolerance)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (path.Count < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var preBend = path[^3];
|
|
var isStandardLBend = Math.Abs(preBend.X - penultimate.X) <= tolerance;
|
|
|
|
if (isStandardLBend)
|
|
{
|
|
// Standard case: vertical segment before horizontal approach
|
|
// Shift both path[^3] and path[^2] to new X
|
|
double precedingX = path.Count >= 4 ? path[^4].X : path[0].X;
|
|
|
|
var maxFeasibleApproach = endpoint.X - precedingX - 1d;
|
|
if (maxFeasibleApproach <= currentApproach + 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d);
|
|
newApproach = Math.Max(newApproach, currentApproach);
|
|
if (newApproach <= currentApproach + 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newVerticalX = endpoint.X - newApproach;
|
|
|
|
if (path.Count >= 4 && path[^4].X > newVerticalX + tolerance)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var extended = path
|
|
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
|
.ToList();
|
|
extended[^2] = new ElkPoint { X = newVerticalX, Y = extended[^2].Y };
|
|
extended[^3] = new ElkPoint { X = newVerticalX, Y = extended[^3].Y };
|
|
|
|
return NormalizePathPoints(extended);
|
|
}
|
|
|
|
// Non-standard case: path[^3] → path[^2] is a short horizontal
|
|
// left-jog before the approach. Look past the jog to find the real
|
|
// vertical segment and extend from there.
|
|
if (Math.Abs(preBend.Y - penultimate.Y) <= tolerance
|
|
&& preBend.X > penultimate.X // jog goes LEFT
|
|
&& preBend.X - penultimate.X < 30d // short jog
|
|
&& path.Count >= 5
|
|
&& Math.Abs(path[^4].X - preBend.X) <= tolerance) // vertical before jog
|
|
{
|
|
// Pattern: ...→(vertX,prevY)→(vertX,endY)→(jogX,endY)→(targetX,endY)
|
|
// Collapse the jog and extend the vertical
|
|
double precedingX = path.Count >= 6 ? path[^5].X : path[0].X;
|
|
|
|
var maxFeasibleApproach = endpoint.X - precedingX - 1d;
|
|
if (maxFeasibleApproach <= currentApproach + 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d);
|
|
newApproach = Math.Max(newApproach, currentApproach);
|
|
if (newApproach <= currentApproach + 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newVerticalX = endpoint.X - newApproach;
|
|
if (path.Count >= 6 && path[^5].X > newVerticalX + tolerance)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Build: keep everything before the vertical, shift the vertical,
|
|
// remove the jog, extend the approach
|
|
var extended = new List<ElkPoint>();
|
|
for (var i = 0; i < path.Count - 4; i++)
|
|
{
|
|
extended.Add(new ElkPoint { X = path[i].X, Y = path[i].Y });
|
|
}
|
|
|
|
extended.Add(new ElkPoint { X = newVerticalX, Y = path[^4].Y });
|
|
extended.Add(new ElkPoint { X = newVerticalX, Y = penultimate.Y });
|
|
extended.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y });
|
|
|
|
return NormalizePathPoints(extended);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static List<ElkPoint>? TryExtendTopFaceApproach(
|
|
IReadOnlyList<ElkPoint> path,
|
|
double desiredMinApproach,
|
|
double tolerance)
|
|
{
|
|
var endpoint = path[^1];
|
|
var penultimate = path[^2];
|
|
var currentApproach = endpoint.Y - penultimate.Y;
|
|
|
|
if (currentApproach <= 0 || currentApproach >= desiredMinApproach - tolerance)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (path.Count < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var preBend = path[^3];
|
|
if (Math.Abs(preBend.Y - penultimate.Y) > tolerance)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
double precedingY;
|
|
if (path.Count >= 4)
|
|
{
|
|
precedingY = path[^4].Y;
|
|
}
|
|
else
|
|
{
|
|
precedingY = path[0].Y;
|
|
}
|
|
|
|
var maxFeasibleApproach = endpoint.Y - precedingY - 1d;
|
|
if (maxFeasibleApproach <= currentApproach + 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d);
|
|
newApproach = Math.Max(newApproach, currentApproach);
|
|
if (newApproach <= currentApproach + 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newHorizontalY = endpoint.Y - newApproach;
|
|
|
|
if (path.Count >= 4 && path[^4].Y > newHorizontalY + tolerance)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var extended = path
|
|
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
|
.ToList();
|
|
extended[^2] = new ElkPoint { X = extended[^2].X, Y = newHorizontalY };
|
|
extended[^3] = new ElkPoint { X = extended[^3].X, Y = newHorizontalY };
|
|
|
|
return NormalizePathPoints(extended);
|
|
}
|
|
}
|