namespace StellaOps.ElkSharp; /// /// Y-axis counterpart to . /// After edges are routed, detects horizontal segments that pass too close to /// (or flush with) non-source/target nodes. Inserts horizontal "gutters" by /// shifting all nodes below the violation Y downward, creating routing space. /// /// This is the architectural fix for under-node, alongside, and shared-lane /// violations: instead of patching edge paths after routing, create adequate /// routing corridors in the node placement so edges route cleanly on the first pass. /// internal static class ElkEdgeHorizontalRoutingGutters { internal static bool ExpandHorizontalRoutingGutters( Dictionary positionedNodes, IReadOnlyCollection routedEdges, IReadOnlyDictionary nodesById, double nodeSpacing, ElkLayoutDirection direction) { if (direction != ElkLayoutDirection.LeftToRight) { return false; } var nodes = positionedNodes.Values.ToArray(); if (nodes.Length == 0) { return false; } var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray(); var minClearance = serviceNodes.Length > 0 ? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d : 50d; // Scan routed edges for horizontal segments with under-node or alongside violations. var gutterRequirements = new Dictionary(); // gutterY → requiredShift foreach (var edge in routedEdges) { foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge)) { if (Math.Abs(segment.Start.Y - segment.End.Y) > 2d) { continue; // not horizontal } var laneY = segment.Start.Y; var minX = Math.Min(segment.Start.X, segment.End.X); var maxX = Math.Max(segment.Start.X, segment.End.X); foreach (var node in nodes) { if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal) || string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal)) { continue; } // Check X overlap if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d) { continue; } var nodeBottom = node.Y + node.Height; // Under-node: lane is below node bottom within clearance var gapBelow = laneY - nodeBottom; if (gapBelow > -4d && gapBelow < minClearance) { var needed = minClearance - gapBelow + 4d; var gutterY = nodeBottom; if (!gutterRequirements.TryGetValue(gutterY, out var existing) || needed > existing) { gutterRequirements[gutterY] = needed; } } // Above-node: lane is above node top within clearance var gapAbove = node.Y - laneY; if (gapAbove > -4d && gapAbove < minClearance) { var needed = minClearance - gapAbove + 4d; var gutterY = laneY - needed; if (!gutterRequirements.TryGetValue(gutterY, out var existing) || needed > existing) { gutterRequirements[gutterY] = needed; } } } } } if (gutterRequirements.Count == 0) { return false; } // Sort gutters from bottom to top (process bottom-most first to avoid // cascading shifts that change gutter positions). var orderedGutters = gutterRequirements .OrderByDescending(g => g.Key) .ToArray(); // Apply shifts: for each gutter, shift all nodes whose center Y is below // the gutter Y downward by the required amount. foreach (var (gutterY, requiredShift) in orderedGutters) { foreach (var nodeId in positionedNodes.Keys.ToArray()) { var node = positionedNodes[nodeId]; var nodeCenterY = node.Y + (node.Height / 2d); if (nodeCenterY <= gutterY) { continue; // node is above the gutter — don't shift } if (!nodesById.TryGetValue(nodeId, out var nodeSpec)) { continue; } positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode( nodeSpec, node.X, node.Y + requiredShift, direction); } } return true; } }