525 lines
17 KiB
C#
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);
|
|
}
|