Forge
csharpdeeb25a2
1#nullable enable
2using Avalonia;
3using CascadeIDE.Models;
4
5namespace CascadeIDE.Cockpit.Graph.Layout;
6
7/// <summary>Иерархия related-files: якорь — корень, спутники — один уровень (не дерево solution explorer).</summary>
8public sealed class GraphRelatedFileHierarchyLayoutEngine : IGraphLayoutEngine
9{
10 private readonly bool _anchorAtTop;
11
12 public GraphRelatedFileHierarchyLayoutEngine(bool anchorAtTop) => _anchorAtTop = anchorAtTop;
13
14 public GraphLayoutScene Layout(
15 GraphDocument doc,
16 double width,
17 double height,
18 CodeNavigationMapDetailLevel detailLevel = CodeNavigationMapDetailLevel.Normal,
19 GraphControlFlowMainAxis? controlFlowMainAxisOverride = null,
20 GraphLayoutEngineOptions layoutOptions = default)
21 {
22 if (width <= 0 || height <= 0)
23 return EmptyScene(width);
24
25 var anchor = doc.Nodes.FirstOrDefault(n => string.Equals(n.Kind, "anchor", StringComparison.OrdinalIgnoreCase))
26 ?? doc.Nodes.FirstOrDefault(n => n.Id.Equals("n0", StringComparison.OrdinalIgnoreCase));
27 var satellites = doc.Nodes
28 .Where(n => anchor is null || !string.Equals(n.Id, anchor.Id, StringComparison.OrdinalIgnoreCase))
29 .OrderBy(n => n.Label, StringComparer.OrdinalIgnoreCase)
30 .ToList();
31
32 var margin = Math.Min(
33 GraphFileLayoutMetrics.SideLabelMargin,
34 Math.Max(28, Math.Min(width, height) * 0.18));
35 var innerW = Math.Max(80, width - 2 * margin);
36 var innerH = Math.Max(80, height - 2 * margin);
37 var cx = width / 2;
38 var scale = Math.Clamp(Math.Min(innerW, innerH) / 220.0, 0.58, ResolveRelatedAutoScaleCeiling(innerW, innerH, satellites.Count));
39 var anchorR = 16 * scale;
40 var satR = 12 * scale;
41 var desiredRowStep = anchorR * 2.6 + satR * 2.2;
42 var minReadableStep = Math.Clamp(desiredRowStep, 18, 52);
43 var maxRowsPerColumn = Math.Max(1, (int)Math.Floor(innerH / minReadableStep));
44 var columns = Math.Clamp((int)Math.Ceiling(satellites.Count / (double)maxRowsPerColumn), 1, 4);
45 var rows = columns == 1 ? satellites.Count : Math.Min(maxRowsPerColumn, satellites.Count);
46 var rowStep = rows > 0 ? innerH / Math.Max(2, rows + 1) : innerH / 2;
47 rowStep = Math.Min(rowStep, minReadableStep);
48
49 var layouts = new List<GraphLayoutNode>();
50 var idToCenter = new Dictionary<string, Point>(StringComparer.OrdinalIgnoreCase);
51 var idToRadius = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
52 Point? anchorCenter = null;
53
54 var nRows = Math.Max(1, satellites.Count + 1);
55 var totalSpan = rowStep * (nRows - 1);
56 var startY = _anchorAtTop
57 ? margin + anchorR
58 : margin + innerH - totalSpan - anchorR;
59 var anchorY = startY;
60 var childStartY = _anchorAtTop ? startY + rowStep : startY - rowStep;
61
62 if (anchor is not null)
63 {
64 var ac = new Point(cx, anchorY);
65 anchorCenter = ac;
66 idToCenter[anchor.Id] = ac;
67 idToRadius[anchor.Id] = anchorR;
68 layouts.Add(MakeNode(anchor, ac, anchorR, isAnchor: true));
69 }
70
71 var colW = innerW / columns;
72 for (var i = 0; i < satellites.Count; i++)
73 {
74 var sat = satellites[i];
75 var col = columns == 1 ? 0 : i / rows;
76 var row = columns == 1 ? i : i % rows;
77 var x = margin + (col + 0.5) * colW;
78 var y = _anchorAtTop
79 ? childStartY + row * rowStep
80 : childStartY - row * rowStep;
81 var p = new Point(x, y);
82 idToCenter[sat.Id] = p;
83 idToRadius[sat.Id] = satR;
84 layouts.Add(MakeNode(sat, p, satR, isAnchor: false));
85 }
86
87 var edgeLayouts = BuildEdges(doc, anchor, anchorCenter, layouts, idToCenter, idToRadius, satR);
88 var layoutKind = _anchorAtTop
89 ? CodeNavigationMapRelatedGraphLayoutKind.TopDown
90 : CodeNavigationMapRelatedGraphLayoutKind.BottomUp;
91
92 return new GraphLayoutScene
93 {
94 Nodes = layouts,
95 Edges = edgeLayouts,
96 Legend = [],
97 UseLegendColumn = false,
98 LegendColumnLeft = width,
99 RelatedFilesLayout = layoutKind
100 };
101 }
102
103 private static GraphLayoutNode MakeNode(GraphNode n, Point center, double radius, bool isAnchor) =>
104 new()
105 {
106 Id = n.Id,
107 Kind = n.Kind,
108 FullPath = n.Path,
109 Label = TruncateLabel(n.Label),
110 Center = center,
111 Radius = radius,
112 IsAnchor = isAnchor,
113 Shape = GraphNodeShape.Rectangle,
114 LineStart = n.LineStart,
115 LineEnd = n.LineEnd
116 };
117
118 private static List<GraphLayoutEdge> BuildEdges(
119 GraphDocument doc,
120 GraphNode? anchor,
121 Point? anchorCenter,
122 List<GraphLayoutNode> layouts,
123 Dictionary<string, Point> idToCenter,
124 Dictionary<string, double> idToRadius,
125 double defaultSatR)
126 {
127 var edgeLayouts = new List<GraphLayoutEdge>();
128 foreach (var e in doc.Edges)
129 {
130 if (!idToCenter.TryGetValue(e.FromId, out var a))
131 continue;
132 if (!idToCenter.TryGetValue(e.ToId, out var b))
133 continue;
134 edgeLayouts.Add(new GraphLayoutEdge
135 {
136 FromNodeId = e.FromId,
137 ToNodeId = e.ToId,
138 From = a,
139 To = b,
140 ToRadius = idToRadius.TryGetValue(e.ToId, out var toR) ? toR : defaultSatR,
141 Kind = e.Kind,
142 RelationKind = e.RelationKind
143 });
144 }
145
146 if (edgeLayouts.Count == 0 && anchorCenter is { } ac0)
147 {
148 foreach (var s in layouts.Where(x => !x.IsAnchor))
149 edgeLayouts.Add(new GraphLayoutEdge
150 {
151 FromNodeId = anchor?.Id ?? "n0",
152 ToNodeId = s.Id,
153 From = ac0,
154 To = s.Center,
155 ToRadius = s.Radius,
156 Kind = null,
157 RelationKind = null
158 });
159 }
160
161 return edgeLayouts;
162 }
163
164 private static GraphLayoutScene EmptyScene(double width) =>
165 new()
166 {
167 Nodes = [],
168 Edges = [],
169 Legend = [],
170 LegendColumnLeft = width
171 };
172
173 private static string TruncateLabel(string label)
174 {
175 // For related-files we want the renderer to handle wrapping inside cards.
176 // Control-flow uses strict budgets; related-files doesn't.
177 var s = label?.Trim() ?? "";
178 if (s.Length <= 80)
179 return s;
180 return s[..77] + "…";
181 }
182
183 private static double ResolveRelatedAutoScaleCeiling(double innerW, double innerH, int satelliteCount)
184 {
185 // Similar policy to radial: if there is a lot of vertical room, make cards larger.
186 // For hierarchy we can be slightly more aggressive: no orbits, mostly vertical flow.
187 var minDim = Math.Min(innerW, innerH);
188 if (satelliteCount <= 4 && minDim >= 260)
189 return 1.90;
190 if (satelliteCount <= 8 && minDim >= 240)
191 return 1.65;
192 if (satelliteCount <= 12 && minDim >= 220)
193 return 1.45;
194 return 1.22;
195 }
196}
197
View only · write via MCP/CIDE