elksharp stabilization
This commit is contained in:
524
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs
Normal file
524
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs
Normal file
@@ -0,0 +1,524 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static class ElkEdgeRouterHighway
|
||||
{
|
||||
private const double MinHighwayRatio = 2d / 5d;
|
||||
private const double BoundaryInset = 4d;
|
||||
private const double MinimumSpreadSpacing = 12d;
|
||||
private const double CoordinateTolerance = 0.5d;
|
||||
|
||||
internal static ElkRoutedEdge[] BreakShortHighways(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length < 2 || nodes.Length == 0)
|
||||
{
|
||||
return edges;
|
||||
}
|
||||
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
|
||||
: 50d;
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(n => n.Y);
|
||||
var graphMaxY = nodes.Max(n => n.Y + n.Height);
|
||||
var result = edges.ToArray();
|
||||
|
||||
foreach (var (key, edgeIndices) in BuildTargetSideGroups(result, nodesById, graphMinY, graphMaxY)
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (edgeIndices.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = key.IndexOf('|', StringComparison.Ordinal);
|
||||
var targetId = key[..separator];
|
||||
var side = key[(separator + 1)..];
|
||||
if (!nodesById.TryGetValue(targetId, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessTargetSideGroup(result, edgeIndices, targetNode, side, minLineClearance);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<ElkHighwayDiagnostics> DetectRemainingBrokenHighways(
|
||||
ElkRoutedEdge[] edges,
|
||||
ElkPositionedNode[] nodes)
|
||||
{
|
||||
if (edges.Length < 2 || nodes.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
|
||||
: 50d;
|
||||
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var graphMinY = nodes.Min(n => n.Y);
|
||||
var graphMaxY = nodes.Max(n => n.Y + n.Height);
|
||||
var detections = new List<ElkHighwayDiagnostics>();
|
||||
|
||||
foreach (var (key, edgeIndices) in BuildTargetSideGroups(edges, nodesById, graphMinY, graphMaxY)
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (edgeIndices.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = key.IndexOf('|', StringComparison.Ordinal);
|
||||
var targetId = key[..separator];
|
||||
var side = key[(separator + 1)..];
|
||||
if (!nodesById.TryGetValue(targetId, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detection = EvaluateTargetSideGroup(edges, edgeIndices, targetNode, side, minLineClearance);
|
||||
if (detection is not null && detection.Value.Diagnostic.WasBroken)
|
||||
{
|
||||
detections.Add(detection.Value.Diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
return detections;
|
||||
}
|
||||
|
||||
private static bool ShouldProcessEdge(ElkRoutedEdge edge, double graphMinY, double graphMaxY)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(edge.Kind)
|
||||
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ProcessTargetSideGroup(
|
||||
ElkRoutedEdge[] result,
|
||||
List<int> edgeIndices,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double minLineClearance)
|
||||
{
|
||||
var evaluation = EvaluateTargetSideGroup(result, edgeIndices, targetNode, side, minLineClearance);
|
||||
if (evaluation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ElkLayoutDiagnostics.AddDetectedHighway(evaluation.Value.Diagnostic);
|
||||
|
||||
if (!evaluation.Value.Diagnostic.WasBroken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ordered = evaluation.Value.Members
|
||||
.OrderBy(member => member.EndpointCoord)
|
||||
.ThenBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var slotCoords = BuildSlotCoordinates(targetNode, side, ordered.Count, minLineClearance);
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var adjustedPath = AdjustPathToTargetSlot(ordered[i].Path, targetNode, side, slotCoords[i], minLineClearance);
|
||||
WriteBackPath(result, ordered[i].Index, adjustedPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<int>> BuildTargetSideGroups(
|
||||
IReadOnlyList<ElkRoutedEdge> edges,
|
||||
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
|
||||
double graphMinY,
|
||||
double graphMaxY)
|
||||
{
|
||||
var edgesByTargetSide = new Dictionary<string, List<int>>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < edges.Count; i++)
|
||||
{
|
||||
var edge = edges[i];
|
||||
if (!ShouldProcessEdge(edge, graphMinY, graphMaxY))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ExtractFullPath(edge);
|
||||
if (path.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var side = ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode);
|
||||
var key = $"{targetNode.Id}|{side}";
|
||||
if (!edgesByTargetSide.TryGetValue(key, out var list))
|
||||
{
|
||||
list = [];
|
||||
edgesByTargetSide[key] = list;
|
||||
}
|
||||
|
||||
list.Add(i);
|
||||
}
|
||||
|
||||
return edgesByTargetSide;
|
||||
}
|
||||
|
||||
private static GroupEvaluation? EvaluateTargetSideGroup(
|
||||
IReadOnlyList<ElkRoutedEdge> edges,
|
||||
IReadOnlyList<int> edgeIndices,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double minLineClearance)
|
||||
{
|
||||
var members = edgeIndices
|
||||
.Select(index => CreateMember(edges[index], index, targetNode, side))
|
||||
.Where(member => member.Path.Count >= 2)
|
||||
.OrderBy(member => member.EdgeId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (members.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pairMetrics = ComputePairMetrics(members);
|
||||
var actualGap = ComputeMinEndpointGap(members, side);
|
||||
var requiresSpread = (actualGap + CoordinateTolerance) < minLineClearance
|
||||
&& !pairMetrics.AllPairsApplicable;
|
||||
if (!requiresSpread && pairMetrics.ShortestSharedRatio < MinHighwayRatio)
|
||||
{
|
||||
requiresSpread = pairMetrics.HasSharedSegment;
|
||||
}
|
||||
|
||||
var diagnostic = new ElkHighwayDiagnostics
|
||||
{
|
||||
TargetNodeId = targetNode.Id,
|
||||
SharedAxis = side,
|
||||
SharedCoord = Math.Round(members.Average(member => member.EndpointCoord), 1),
|
||||
EdgeIds = members.Select(member => member.EdgeId).ToArray(),
|
||||
MinRatio = pairMetrics.HasSharedSegment
|
||||
? Math.Round(pairMetrics.ShortestSharedRatio, 3)
|
||||
: 0d,
|
||||
WasBroken = requiresSpread,
|
||||
Reason = requiresSpread
|
||||
? pairMetrics.HasSharedSegment && pairMetrics.ShortestSharedRatio < MinHighwayRatio
|
||||
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} < {MinHighwayRatio:F2}"
|
||||
: $"gap {actualGap:F0}px < clearance {minLineClearance:F0}px"
|
||||
: pairMetrics.AllPairsApplicable
|
||||
? $"shared ratio {pairMetrics.ShortestSharedRatio:F2} >= {MinHighwayRatio:F2}"
|
||||
: $"gap {actualGap:F0}px >= clearance {minLineClearance:F0}px",
|
||||
};
|
||||
|
||||
return new GroupEvaluation(members, diagnostic);
|
||||
}
|
||||
|
||||
private static HighwayPairMetrics ComputePairMetrics(IReadOnlyList<HighwayMember> members)
|
||||
{
|
||||
var shortestSharedRatio = double.MaxValue;
|
||||
var hasSharedSegment = false;
|
||||
var allPairsApplicable = true;
|
||||
|
||||
for (var i = 0; i < members.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < members.Count; j++)
|
||||
{
|
||||
var sharedLength = ElkEdgeRoutingGeometry.ComputeLongestSharedApproachSegmentLength(
|
||||
members[i].Path,
|
||||
members[j].Path);
|
||||
if (sharedLength <= 1d)
|
||||
{
|
||||
allPairsApplicable = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
hasSharedSegment = true;
|
||||
var shortestPath = Math.Min(members[i].PathLength, members[j].PathLength);
|
||||
if (shortestPath <= 1d)
|
||||
{
|
||||
allPairsApplicable = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var ratio = sharedLength / shortestPath;
|
||||
shortestSharedRatio = Math.Min(shortestSharedRatio, ratio);
|
||||
if (ratio < MinHighwayRatio)
|
||||
{
|
||||
allPairsApplicable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HighwayPairMetrics(
|
||||
HasSharedSegment: hasSharedSegment,
|
||||
AllPairsApplicable: allPairsApplicable && hasSharedSegment,
|
||||
ShortestSharedRatio: hasSharedSegment ? shortestSharedRatio : 0d);
|
||||
}
|
||||
|
||||
private static double ComputeMinEndpointGap(IReadOnlyList<HighwayMember> members, string side)
|
||||
{
|
||||
var coords = members
|
||||
.Select(member => member.EndpointCoord)
|
||||
.OrderBy(value => value)
|
||||
.ToArray();
|
||||
if (coords.Length < 2)
|
||||
{
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
var minGap = double.MaxValue;
|
||||
for (var i = 1; i < coords.Length; i++)
|
||||
{
|
||||
minGap = Math.Min(minGap, coords[i] - coords[i - 1]);
|
||||
}
|
||||
|
||||
return minGap;
|
||||
}
|
||||
|
||||
private static double[] BuildSlotCoordinates(
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
int count,
|
||||
double minLineClearance)
|
||||
{
|
||||
if (count <= 1)
|
||||
{
|
||||
return
|
||||
[
|
||||
side is "left" or "right"
|
||||
? targetNode.Y + (targetNode.Height / 2d)
|
||||
: targetNode.X + (targetNode.Width / 2d),
|
||||
];
|
||||
}
|
||||
|
||||
var axisMin = side is "left" or "right"
|
||||
? targetNode.Y + BoundaryInset
|
||||
: targetNode.X + BoundaryInset;
|
||||
var axisMax = side is "left" or "right"
|
||||
? targetNode.Y + targetNode.Height - BoundaryInset
|
||||
: targetNode.X + targetNode.Width - BoundaryInset;
|
||||
var axisLength = Math.Max(8d, axisMax - axisMin);
|
||||
var spacing = Math.Max(
|
||||
MinimumSpreadSpacing,
|
||||
Math.Min(minLineClearance, axisLength / (count - 1)));
|
||||
var totalSpan = (count - 1) * spacing;
|
||||
var center = (axisMin + axisMax) / 2d;
|
||||
var start = Math.Max(axisMin, Math.Min(center - (totalSpan / 2d), axisMax - totalSpan));
|
||||
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(index => Math.Min(axisMax, start + (index * spacing)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static List<ElkPoint> AdjustPathToTargetSlot(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
double slotCoord,
|
||||
double minLineClearance)
|
||||
{
|
||||
var adjusted = path
|
||||
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
||||
.ToList();
|
||||
if (adjusted.Count < 2)
|
||||
{
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
if (side is "left" or "right")
|
||||
{
|
||||
var targetX = side == "left"
|
||||
? targetNode.X
|
||||
: targetNode.X + targetNode.Width;
|
||||
|
||||
while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].X - targetX) <= CoordinateTolerance)
|
||||
{
|
||||
adjusted.RemoveAt(adjusted.Count - 2);
|
||||
}
|
||||
|
||||
var anchor = adjusted[^2];
|
||||
var rebuilt = adjusted.Take(adjusted.Count - 1).ToList();
|
||||
if (Math.Abs(anchor.X - targetX) <= CoordinateTolerance)
|
||||
{
|
||||
var offset = Math.Max(24d, minLineClearance / 2d);
|
||||
rebuilt.Add(new ElkPoint
|
||||
{
|
||||
X = side == "left" ? targetX - offset : targetX + offset,
|
||||
Y = anchor.Y,
|
||||
});
|
||||
anchor = rebuilt[^1];
|
||||
}
|
||||
|
||||
if (Math.Abs(anchor.Y - slotCoord) > CoordinateTolerance)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint { X = anchor.X, Y = slotCoord });
|
||||
}
|
||||
|
||||
rebuilt.Add(new ElkPoint { X = targetX, Y = slotCoord });
|
||||
return NormalizePath(rebuilt);
|
||||
}
|
||||
|
||||
var targetY = side == "top"
|
||||
? targetNode.Y
|
||||
: targetNode.Y + targetNode.Height;
|
||||
while (adjusted.Count >= 3 && Math.Abs(adjusted[^2].Y - targetY) <= CoordinateTolerance)
|
||||
{
|
||||
adjusted.RemoveAt(adjusted.Count - 2);
|
||||
}
|
||||
|
||||
var verticalAnchor = adjusted[^2];
|
||||
var verticalRebuilt = adjusted.Take(adjusted.Count - 1).ToList();
|
||||
if (Math.Abs(verticalAnchor.Y - targetY) <= CoordinateTolerance)
|
||||
{
|
||||
var offset = Math.Max(24d, minLineClearance / 2d);
|
||||
verticalRebuilt.Add(new ElkPoint
|
||||
{
|
||||
X = verticalAnchor.X,
|
||||
Y = side == "top" ? targetY - offset : targetY + offset,
|
||||
});
|
||||
verticalAnchor = verticalRebuilt[^1];
|
||||
}
|
||||
|
||||
if (Math.Abs(verticalAnchor.X - slotCoord) > CoordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = verticalAnchor.Y });
|
||||
}
|
||||
|
||||
verticalRebuilt.Add(new ElkPoint { X = slotCoord, Y = targetY });
|
||||
return NormalizePath(verticalRebuilt);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizePath(IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
var deduped = new List<ElkPoint>();
|
||||
foreach (var point in path)
|
||||
{
|
||||
if (deduped.Count == 0 || !ElkEdgeRoutingGeometry.PointsEqual(deduped[^1], point))
|
||||
{
|
||||
deduped.Add(point);
|
||||
}
|
||||
}
|
||||
|
||||
if (deduped.Count <= 2)
|
||||
{
|
||||
return deduped;
|
||||
}
|
||||
|
||||
var simplified = new List<ElkPoint> { deduped[0] };
|
||||
for (var i = 1; i < deduped.Count - 1; i++)
|
||||
{
|
||||
var previous = simplified[^1];
|
||||
var current = deduped[i];
|
||||
var next = deduped[i + 1];
|
||||
var sameX = Math.Abs(previous.X - current.X) <= CoordinateTolerance
|
||||
&& Math.Abs(current.X - next.X) <= CoordinateTolerance;
|
||||
var sameY = Math.Abs(previous.Y - current.Y) <= CoordinateTolerance
|
||||
&& Math.Abs(current.Y - next.Y) <= CoordinateTolerance;
|
||||
if (!sameX && !sameY)
|
||||
{
|
||||
simplified.Add(current);
|
||||
}
|
||||
}
|
||||
|
||||
simplified.Add(deduped[^1]);
|
||||
return simplified;
|
||||
}
|
||||
|
||||
private static HighwayMember CreateMember(
|
||||
ElkRoutedEdge edge,
|
||||
int index,
|
||||
ElkPositionedNode targetNode,
|
||||
string side)
|
||||
{
|
||||
var path = ExtractFullPath(edge);
|
||||
var endpointCoord = side is "left" or "right"
|
||||
? path[^1].Y
|
||||
: path[^1].X;
|
||||
return new HighwayMember(
|
||||
Index: index,
|
||||
EdgeId: edge.Id,
|
||||
Edge: edge,
|
||||
Path: path,
|
||||
PathLength: ElkEdgeRoutingGeometry.ComputePathLength(edge),
|
||||
EndpointCoord: endpointCoord);
|
||||
}
|
||||
|
||||
private static void WriteBackPath(ElkRoutedEdge[] result, int edgeIndex, IReadOnlyList<ElkPoint> path)
|
||||
{
|
||||
var edge = result[edgeIndex];
|
||||
result[edgeIndex] = new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
SourcePortId = edge.SourcePortId,
|
||||
TargetPortId = edge.TargetPortId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = path[0],
|
||||
EndPoint = path[^1],
|
||||
BendPoints = path.Count > 2
|
||||
? path.Skip(1).Take(path.Count - 2).ToArray()
|
||||
: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ElkPoint> ExtractFullPath(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 HighwayMember(
|
||||
int Index,
|
||||
string EdgeId,
|
||||
ElkRoutedEdge Edge,
|
||||
List<ElkPoint> Path,
|
||||
double PathLength,
|
||||
double EndpointCoord);
|
||||
|
||||
private readonly record struct HighwayPairMetrics(
|
||||
bool HasSharedSegment,
|
||||
bool AllPairsApplicable,
|
||||
double ShortestSharedRatio);
|
||||
|
||||
private readonly record struct GroupEvaluation(
|
||||
IReadOnlyList<HighwayMember> Members,
|
||||
ElkHighwayDiagnostics Diagnostic);
|
||||
}
|
||||
Reference in New Issue
Block a user