Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterHighway.cs
2026-03-24 08:38:09 +02:00

525 lines
17 KiB
C#

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);
}