namespace StellaOps.ElkSharp; internal static partial class ElkEdgePostProcessor { internal static ElkRoutedEdge[] SpreadSourceDepartureJoins( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, double minLineClearance, IReadOnlyCollection? restrictedEdgeIds = null) { if (edges.Length == 0 || nodes.Length == 0) { return edges; } var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); var graphMinY = nodes.Min(node => node.Y); var graphMaxY = nodes.Max(node => node.Y + node.Height); var restrictedSet = restrictedEdgeIds is null ? null : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); var result = edges.ToArray(); var groups = result .Select((edge, index) => new { Edge = edge, Index = index, Path = ExtractFullPath(edge), }) .Where(item => item.Path.Count >= 2 && nodesById.TryGetValue(item.Edge.SourceNodeId ?? string.Empty, out _) && ShouldSpreadSourceDeparture(item.Edge, graphMinY, graphMaxY)) .GroupBy( item => { var sourceNode = nodesById[item.Edge.SourceNodeId ?? string.Empty]; var side = ResolveSourceDepartureSide(item.Path, sourceNode); return $"{sourceNode.Id}|{side}"; }, StringComparer.Ordinal); foreach (var group in groups) { var entries = group .Select(item => { var sourceNode = nodesById[item.Edge.SourceNodeId ?? string.Empty]; var side = ResolveSourceDepartureSide(item.Path, sourceNode); return new { item.Edge, item.Index, item.Path, SourceNode = sourceNode, Side = side, Boundary = item.Path[0], TargetReference = side is "left" or "right" ? item.Path[^1].Y : item.Path[^1].X, PathLength = ComputePathLength(item.Path), }; }) .ToArray(); if (entries.Length < 2) { continue; } if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) { continue; } var sourceNode = entries[0].SourceNode; var side = entries[0].Side; var sourceBoundaryEntries = entries .Select(entry => ( entry.Edge.Id, Coordinate: side is "left" or "right" ? entry.Boundary.Y : entry.Boundary.X, IsOutgoing: true)) .ToArray(); var joinEntries = entries .Select(entry => (Path: (IReadOnlyList)entry.Path, Side: entry.Side)) .ToArray(); var hasDepartureJoin = GroupHasSourceDepartureJoin(joinEntries, minLineClearance); var hasBoundarySlotIssue = HasBoundarySlotAlignmentIssue( sourceBoundaryEntries, sourceNode, side, minLineClearance); if (!hasDepartureJoin && !hasBoundarySlotIssue) { continue; } var isGatewaySource = ElkShapeBoundaries.IsGatewayShape(sourceNode); var slotOnlyRepair = !hasDepartureJoin && hasBoundarySlotIssue; var boundaryCoordinate = side is "left" or "right" ? entries[0].Boundary.Y : entries[0].Boundary.X; var anchor = entries .OrderBy(entry => Math.Abs(entry.TargetReference - boundaryCoordinate)) .ThenBy(entry => entry.PathLength) .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) .First(); var sorted = entries .OrderBy(entry => entry.TargetReference) .ThenBy(entry => entry.PathLength) .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) .ToArray(); var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates( sourceNode, side, sorted.Select(entry => entry.TargetReference).ToArray()); var desiredCoordinateByEdgeId = new Dictionary(StringComparer.Ordinal); var anchorDepartureAxis = TryExtractSourceDepartureRun(anchor.Path, side, out _, out var anchorRunEndIndex) ? side is "left" or "right" ? anchor.Path[anchorRunEndIndex].X : anchor.Path[anchorRunEndIndex].Y : side switch { "left" => sourceNode.X - 24d, "right" => sourceNode.X + sourceNode.Width + 24d, "top" => sourceNode.Y - 24d, "bottom" => sourceNode.Y + sourceNode.Height + 24d, _ => 0d, }; var desiredAxisByEdgeId = new Dictionary(StringComparer.Ordinal); for (var i = 0; i < sorted.Length; i++) { desiredCoordinateByEdgeId[sorted[i].Edge.Id] = assignedSlotCoordinates[i]; desiredAxisByEdgeId[sorted[i].Edge.Id] = slotOnlyRepair ? TryExtractSourceDepartureRun(sorted[i].Path, side, out _, out var sortedRunEndIndex) ? side is "left" or "right" ? sorted[i].Path[sortedRunEndIndex].X : sorted[i].Path[sortedRunEndIndex].Y : anchorDepartureAxis : anchorDepartureAxis; } foreach (var entry in entries) { if (!desiredCoordinateByEdgeId.TryGetValue(entry.Edge.Id, out var slotCoordinate)) { continue; } if (!desiredAxisByEdgeId.TryGetValue(entry.Edge.Id, out var desiredAxis)) { continue; } var originalCoordinate = side is "left" or "right" ? entry.Boundary.Y : entry.Boundary.X; var originalAxis = TryExtractSourceDepartureRun(entry.Path, side, out _, out var runEndIndex) ? side is "left" or "right" ? entry.Path[runEndIndex].X : entry.Path[runEndIndex].Y : desiredAxis; if (Math.Abs(originalCoordinate - slotCoordinate) <= 0.5d && Math.Abs(originalAxis - desiredAxis) <= 0.5d) { continue; } var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(sourceNode, side, slotCoordinate); if (isGatewaySource) { var continuation = entry.Path.Count > 1 ? entry.Path[1] : entry.Path[0]; boundaryPoint = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundaryPoint, continuation); } var candidate = BuildSourceDepartureCandidatePath( entry.Path, sourceNode, side, boundaryPoint, desiredAxis, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId); if (!PathChanged(entry.Path, candidate)) { continue; } if (isGatewaySource) { if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, sourceNode, fromStart: true)) { continue; } } else { if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, true, 2) || !HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode) || HasNodeObstacleCrossing(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId)) { continue; } } result[entry.Index] = BuildSingleSectionEdge(entry.Edge, candidate); } } return result; } internal static ElkRoutedEdge[] SpreadRectTargetApproachFeederBands( ElkRoutedEdge[] edges, ElkPositionedNode[] nodes, double minLineClearance, IReadOnlyCollection? restrictedEdgeIds = null) { if (edges.Length < 2 || nodes.Length == 0) { return edges; } var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); var restrictedSet = restrictedEdgeIds is null ? null : restrictedEdgeIds.ToHashSet(StringComparer.Ordinal); var result = edges.ToArray(); var groups = result .Select((edge, index) => new { Edge = edge, Index = index, Path = ExtractFullPath(edge), }) .Where(item => item.Path.Count >= 3 && nodesById.TryGetValue(item.Edge.TargetNodeId ?? string.Empty, out var targetNode) && !ElkShapeBoundaries.IsGatewayShape(targetNode)) .GroupBy( item => { var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty]; var side = ResolveTargetApproachSide(item.Path, targetNode); return $"{targetNode.Id}|{side}"; }, StringComparer.Ordinal); foreach (var group in groups) { var entries = group .Select(item => { var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty]; var side = ResolveTargetApproachSide(item.Path, targetNode); return TryExtractTargetApproachFeeder(item.Path, side, out var feeder) ? new { item.Edge, item.Index, item.Path, TargetNode = targetNode, Side = side, Feeder = feeder, } : null; }) .Where(entry => entry is not null) .Select(entry => entry!) .ToArray(); if (entries.Length < 2) { continue; } if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) { continue; } var conflictNeighbors = new List[entries.Length]; for (var i = 0; i < entries.Length; i++) { conflictNeighbors[i] = []; } var hasConflict = false; for (var i = 0; i < entries.Length; i++) { for (var j = i + 1; j < entries.Length; j++) { if (ElkEdgeRoutingGeometry.AreParallelAndClose( entries[i].Feeder.Start, entries[i].Feeder.End, entries[j].Feeder.Start, entries[j].Feeder.End, minLineClearance)) { hasConflict = true; conflictNeighbors[i].Add(j); conflictNeighbors[j].Add(i); } } } if (!hasConflict) { continue; } var visited = new bool[entries.Length]; for (var componentStart = 0; componentStart < entries.Length; componentStart++) { if (visited[componentStart] || conflictNeighbors[componentStart].Count == 0) { continue; } var queue = new Queue(); var componentIndices = new List(); queue.Enqueue(componentStart); visited[componentStart] = true; while (queue.Count > 0) { var current = queue.Dequeue(); componentIndices.Add(current); foreach (var neighbor in conflictNeighbors[current]) { if (visited[neighbor]) { continue; } visited[neighbor] = true; queue.Enqueue(neighbor); } } if (componentIndices.Count < 2) { continue; } var componentEntries = componentIndices .Select(index => entries[index]) .ToArray(); if (restrictedSet is not null && !componentEntries.Any(entry => restrictedSet.Contains(entry.Edge.Id))) { continue; } var spacing = Math.Max(12d, minLineClearance + 4d); var sorted = componentEntries .OrderBy(entry => entry.Feeder.BandCoordinate) .ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal) .ToArray(); var baseBand = sorted[0].Side is "left" or "top" ? sorted.Max(entry => entry.Feeder.BandCoordinate) : sorted.Min(entry => entry.Feeder.BandCoordinate); for (var i = 0; i < sorted.Length; i++) { var desiredBand = ResolveDesiredTargetApproachAxis( sorted[i].TargetNode, sorted[i].Side, baseBand, spacing, i, forceOutwardFromBoundary: true); if (Math.Abs(sorted[i].Feeder.BandCoordinate - desiredBand) <= 0.5d) { continue; } var candidate = RewriteTargetApproachFeederBand(sorted[i].Path, sorted[i].Side, desiredBand); if (!PathChanged(sorted[i].Path, candidate) || !HasClearBoundarySegments(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId, false, 4) || HasNodeObstacleCrossing(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId)) { continue; } result[sorted[i].Index] = BuildSingleSectionEdge(sorted[i].Edge, candidate); } } } return result; } }