| 1 | using System.Globalization; |
| 2 | using Avalonia; |
| 3 | using Avalonia.Controls.Documents; |
| 4 | using Avalonia.Media; |
| 5 | using CascadeIDE.Cockpit.Graph.Layout; |
| 6 | |
| 7 | namespace CascadeIDE.Views.SkiaKit.Graph; |
| 8 | |
| 9 | public static partial class SkiaGraphSceneDrawing |
| 10 | { |
| 11 | private static void DrawEdges(DrawingContext context, GraphLayoutScene scene, SkiaGraphVisualTheme theme) |
| 12 | { |
| 13 | var previousWasLoop = false; |
| 14 | foreach (var edge in scene.Edges) |
| 15 | { |
| 16 | var isHighlighted = scene.HighlightedEdgeKeys.Contains(edge.Key); |
| 17 | var isLoop = IsLoopEdge(edge.Kind); |
| 18 | if (isLoop && !previousWasLoop) |
| 19 | { |
| 20 | var baseLoopPen = isHighlighted ? theme.HighlightedLoopEdgePen : theme.LoopEdgePen; |
| 21 | var loopPen = ScaleLoopPen(baseLoopPen, ResolveLoopWeightScale(scene, edge), isHighlighted); |
| 22 | DrawLoopEdge(context, scene, edge, loopPen); |
| 23 | previousWasLoop = true; |
| 24 | continue; |
| 25 | } |
| 26 | |
| 27 | var edgeStyle = ResolveEdgePen(theme, edge.Kind, isHighlighted); |
| 28 | var fromR = GetNodeRadius(scene, edge.FromNodeId, fallback: 12); |
| 29 | DrawCubicEdge(context, edgeStyle, edge.From, fromR, edge.To, edge.ToRadius); |
| 30 | if (!string.IsNullOrEmpty(edge.BranchLabel)) |
| 31 | DrawBranchLabel(context, theme, scene, edge, fromR, edge.BranchLabel); |
| 32 | previousWasLoop = isLoop; |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | private static Pen ResolveEdgePen(SkiaGraphVisualTheme theme, string? kind, bool highlighted) |
| 37 | { |
| 38 | if (highlighted) |
| 39 | return theme.HighlightedEdgePen; |
| 40 | if (IsMultiBranchEdge(kind)) |
| 41 | return theme.MultiBranchEdgePen; |
| 42 | if (IsConditionalEdge(kind)) |
| 43 | return theme.ConditionalEdgePen; |
| 44 | if (IsExceptionFlowEdge(kind)) |
| 45 | return theme.ConditionalEdgePen; |
| 46 | return theme.BaseEdgePen; |
| 47 | } |
| 48 | |
| 49 | private static bool IsLoopEdge(string? kind) => |
| 50 | !string.IsNullOrWhiteSpace(kind) |
| 51 | && kind.Contains("loop", StringComparison.OrdinalIgnoreCase); |
| 52 | |
| 53 | private static bool IsMultiBranchEdge(string? kind) => |
| 54 | !string.IsNullOrWhiteSpace(kind) |
| 55 | && kind.Contains("multibranch", StringComparison.OrdinalIgnoreCase); |
| 56 | |
| 57 | private static bool IsConditionalEdge(string? kind) => |
| 58 | !string.IsNullOrWhiteSpace(kind) |
| 59 | && kind.Contains("conditional", StringComparison.OrdinalIgnoreCase); |
| 60 | |
| 61 | private static bool IsExceptionFlowEdge(string? kind) => |
| 62 | !string.IsNullOrWhiteSpace(kind) |
| 63 | && kind.Contains("exception", StringComparison.OrdinalIgnoreCase); |
| 64 | |
| 65 | /// <summary> |
| 66 | /// ADR 0053: «петля на ребре» — один штрих пунктирной кубики с усиленным боковым выносом к цели, без второго декоративного эллипса по узлу. |
| 67 | /// </summary> |
| 68 | private static void DrawLoopEdge( |
| 69 | DrawingContext context, |
| 70 | GraphLayoutScene scene, |
| 71 | GraphLayoutEdge edge, |
| 72 | Pen loopPen) |
| 73 | { |
| 74 | var fromR = GetNodeRadius(scene, edge.FromNodeId, fallback: 12); |
| 75 | DrawCubicEdge( |
| 76 | context, |
| 77 | loopPen, |
| 78 | edge.From, |
| 79 | fromR, |
| 80 | edge.To, |
| 81 | edge.ToRadius, |
| 82 | lateralBendMultiplier: LoopEdgeOrbitBendMultiplier(scene, edge)); |
| 83 | } |
| 84 | |
| 85 | /// <summary>Чуть сильнее «орбита» при вертикальном главном потоке и при большей группе цикла (ADR 0053).</summary> |
| 86 | private static double LoopEdgeOrbitBendMultiplier(GraphLayoutScene scene, GraphLayoutEdge edge) |
| 87 | { |
| 88 | var baseMul = scene.ControlFlowMainAxis == GraphControlFlowMainAxis.Horizontal ? 2.05 : 2.2; |
| 89 | var dx = edge.To.X - edge.From.X; |
| 90 | var dy = edge.To.Y - edge.From.Y; |
| 91 | var len = Math.Sqrt(dx * dx + dy * dy); |
| 92 | if (len > 140) |
| 93 | baseMul *= 1.05; |
| 94 | var members = ResolveLoopGroupMemberCount(scene, edge); |
| 95 | if (members > 1) |
| 96 | baseMul *= 1.0 + Math.Min(0.18, (members - 1) * 0.04); |
| 97 | return baseMul; |
| 98 | } |
| 99 | |
| 100 | /// <summary>Толщина/контраст петли по числу узлов в <see cref="GraphLayoutNode.LoopGroupId"/>.</summary> |
| 101 | private static double ResolveLoopWeightScale(GraphLayoutScene scene, GraphLayoutEdge edge) |
| 102 | { |
| 103 | var members = ResolveLoopGroupMemberCount(scene, edge); |
| 104 | return members switch |
| 105 | { |
| 106 | <= 1 => 1.0, |
| 107 | 2 => 1.06, |
| 108 | 3 => 1.12, |
| 109 | 4 => 1.17, |
| 110 | _ => 1.22 |
| 111 | }; |
| 112 | } |
| 113 | |
| 114 | private static int ResolveLoopGroupMemberCount(GraphLayoutScene scene, GraphLayoutEdge edge) |
| 115 | { |
| 116 | int? groupId = null; |
| 117 | foreach (var n in scene.Nodes) |
| 118 | { |
| 119 | if (n.LoopGroupId is not > 0) |
| 120 | continue; |
| 121 | if (string.Equals(n.Id, edge.ToNodeId, StringComparison.OrdinalIgnoreCase) |
| 122 | || string.Equals(n.Id, edge.FromNodeId, StringComparison.OrdinalIgnoreCase)) |
| 123 | { |
| 124 | groupId = n.LoopGroupId; |
| 125 | break; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | if (groupId is null) |
| 130 | return 1; |
| 131 | |
| 132 | var count = 0; |
| 133 | foreach (var n in scene.Nodes) |
| 134 | { |
| 135 | if (n.LoopGroupId == groupId) |
| 136 | count++; |
| 137 | } |
| 138 | |
| 139 | return Math.Max(1, count); |
| 140 | } |
| 141 | |
| 142 | private static Pen ScaleLoopPen(Pen source, double scale, bool highlighted) |
| 143 | { |
| 144 | if (Math.Abs(scale - 1.0) < 0.01) |
| 145 | return source; |
| 146 | |
| 147 | var thickness = source.Thickness * scale; |
| 148 | IBrush brush = source.Brush ?? Brushes.White; |
| 149 | if (!highlighted && brush is SolidColorBrush scb) |
| 150 | { |
| 151 | var c = scb.Color; |
| 152 | var alphaBoost = (byte)Math.Clamp(c.A + (int)((scale - 1.0) * 48), c.A, 255); |
| 153 | brush = new SolidColorBrush(Color.FromArgb(alphaBoost, c.R, c.G, c.B)); |
| 154 | } |
| 155 | |
| 156 | return new Pen(brush, thickness) { DashStyle = source.DashStyle, LineCap = source.LineCap }; |
| 157 | } |
| 158 | |
| 159 | private static void DrawBranchLabel( |
| 160 | DrawingContext context, |
| 161 | SkiaGraphVisualTheme theme, |
| 162 | GraphLayoutScene scene, |
| 163 | GraphLayoutEdge edge, |
| 164 | double fromRadius, |
| 165 | string label) |
| 166 | { |
| 167 | var dx = edge.To.X - edge.From.X; |
| 168 | var dy = edge.To.Y - edge.From.Y; |
| 169 | var len = Math.Sqrt(dx * dx + dy * dy); |
| 170 | if (len < 1e-6) |
| 171 | return; |
| 172 | var ux = dx / len; |
| 173 | var uy = dy / len; |
| 174 | var start = new Point(edge.From.X + ux * fromRadius, edge.From.Y + uy * fromRadius); |
| 175 | var end = edge.ToRadius is { } tr |
| 176 | ? new Point(edge.To.X - ux * tr, edge.To.Y - uy * tr) |
| 177 | : edge.To; |
| 178 | var t = 0.38; |
| 179 | var anchor = new Point(start.X + (end.X - start.X) * t, start.Y + (end.Y - start.Y) * t); |
| 180 | var px = -uy; |
| 181 | var py = ux; |
| 182 | var offset = 10; |
| 183 | anchor = new Point(anchor.X + px * offset, anchor.Y + py * offset); |
| 184 | |
| 185 | var fontSize = scene.SideLabelFontSizePx ?? SkiaGraphRenderInvariants.MinSideLabelFontSize; |
| 186 | var ft = new FormattedText( |
| 187 | label, |
| 188 | CultureInfo.InvariantCulture, |
| 189 | FlowDirection.LeftToRight, |
| 190 | theme.SideLabelTypeface, |
| 191 | Math.Max(8.5, fontSize * 0.92), |
| 192 | theme.SideLabelBrush); |
| 193 | context.DrawText(ft, new Point(anchor.X - ft.Width / 2, anchor.Y - ft.Height / 2)); |
| 194 | } |
| 195 | |
| 196 | private static double GetNodeRadius(GraphLayoutScene scene, string nodeId, double fallback) |
| 197 | { |
| 198 | foreach (var n in scene.Nodes) |
| 199 | { |
| 200 | if (string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase)) |
| 201 | return n.Radius; |
| 202 | } |
| 203 | |
| 204 | return fallback; |
| 205 | } |
| 206 | |
| 207 | private static void DrawCubicEdge( |
| 208 | DrawingContext context, |
| 209 | Pen pen, |
| 210 | Point fromCenter, |
| 211 | double fromRadius, |
| 212 | Point to, |
| 213 | double? toRadius, |
| 214 | double lateralBendMultiplier = 1.0) |
| 215 | { |
| 216 | var dx = to.X - fromCenter.X; |
| 217 | var dy = to.Y - fromCenter.Y; |
| 218 | var len = Math.Sqrt(dx * dx + dy * dy); |
| 219 | if (len < 1e-6) |
| 220 | return; |
| 221 | var ux = dx / len; |
| 222 | var uy = dy / len; |
| 223 | var start = new Point(fromCenter.X + ux * fromRadius, fromCenter.Y + uy * fromRadius); |
| 224 | Point end = toRadius is { } tr |
| 225 | ? new Point(to.X - ux * tr, to.Y - uy * tr) |
| 226 | : to; |
| 227 | |
| 228 | var ex = end.X - start.X; |
| 229 | var ey = end.Y - start.Y; |
| 230 | var elen = Math.Sqrt(ex * ex + ey * ey); |
| 231 | if (elen < 1e-6) |
| 232 | return; |
| 233 | if (elen < 6) |
| 234 | { |
| 235 | context.DrawLine(pen, start, end); |
| 236 | DrawArrowHeadAtTip(context, pen, end, ex / elen, ey / elen); |
| 237 | return; |
| 238 | } |
| 239 | |
| 240 | // Для почти вертикальных / последовательных шагов — прямая, кроме рёбер с принудительной орбитой (loop). |
| 241 | var horizontalDrift = Math.Abs(ex); |
| 242 | var isNearStraightFlow = horizontalDrift <= Math.Max(8, elen * 0.08); |
| 243 | if (isNearStraightFlow && lateralBendMultiplier <= 1.02) |
| 244 | { |
| 245 | context.DrawLine(pen, start, end); |
| 246 | DrawArrowHeadAtTip(context, pen, end, ex / elen, ey / elen); |
| 247 | return; |
| 248 | } |
| 249 | |
| 250 | var bendCap = Math.Clamp(elen * 0.22, 18, 56); |
| 251 | var bendByDistance = Math.Min(bendCap, elen * 0.2); |
| 252 | var bendByHorizontalRoom = Math.Max(0, horizontalDrift * 0.45); |
| 253 | var crossRoom = lateralBendMultiplier > 1.02 |
| 254 | ? Math.Max(bendByHorizontalRoom, Math.Clamp(elen * 0.085, 12, 44)) |
| 255 | : bendByHorizontalRoom; |
| 256 | var bend = Math.Min(bendByDistance, crossRoom) * lateralBendMultiplier; |
| 257 | var px = -ey / elen; |
| 258 | var py = ex / elen; |
| 259 | var c1 = new Point(start.X + ex / 3 + px * bend, start.Y + ey / 3 + py * bend); |
| 260 | var c2 = new Point(end.X - ex / 3 + px * bend, end.Y - ey / 3 + py * bend); |
| 261 | |
| 262 | var geometry = new StreamGeometry(); |
| 263 | using (var ig = geometry.Open()) |
| 264 | { |
| 265 | ig.BeginFigure(start, false); |
| 266 | ig.CubicBezierTo(c1, c2, end); |
| 267 | } |
| 268 | |
| 269 | context.DrawGeometry(null, pen, geometry); |
| 270 | var tdx = end.X - c2.X; |
| 271 | var tdy = end.Y - c2.Y; |
| 272 | var tlen = Math.Sqrt(tdx * tdx + tdy * tdy); |
| 273 | if (tlen < 1e-6) |
| 274 | { |
| 275 | tdx = end.X - start.X; |
| 276 | tdy = end.Y - start.Y; |
| 277 | tlen = Math.Sqrt(tdx * tdx + tdy * tdy); |
| 278 | } |
| 279 | |
| 280 | if (tlen >= 1e-6) |
| 281 | DrawArrowHeadAtTip(context, pen, end, tdx / tlen, tdy / tlen); |
| 282 | } |
| 283 | |
| 284 | private static void DrawArrowHeadAtTip(DrawingContext context, Pen pen, Point tip, double dirX, double dirY) |
| 285 | { |
| 286 | var brush = pen.Brush ?? Brushes.White; |
| 287 | var thickness = pen.Thickness; |
| 288 | if (thickness <= 0) |
| 289 | thickness = 1; |
| 290 | var arrowLen = 6 + Math.Min(5, thickness * 1.8); |
| 291 | var halfW = arrowLen * 0.45; |
| 292 | var bx = tip.X - dirX * arrowLen; |
| 293 | var by = tip.Y - dirY * arrowLen; |
| 294 | var px = -dirY; |
| 295 | var py = dirX; |
| 296 | var p0 = new Point(bx + px * halfW, by + py * halfW); |
| 297 | var p1 = new Point(bx - px * halfW, by - py * halfW); |
| 298 | var geo = new StreamGeometry(); |
| 299 | using (var ig = geo.Open()) |
| 300 | { |
| 301 | ig.BeginFigure(tip, true); |
| 302 | ig.LineTo(p0); |
| 303 | ig.LineTo(p1); |
| 304 | ig.EndFigure(true); |
| 305 | } |
| 306 | |
| 307 | context.DrawGeometry(brush, null, geo); |
| 308 | } |
| 309 | } |
| 310 | |