Forge
csharpdeeb25a2
1#nullable enable
2using Avalonia;
3using CascadeIDE.Cockpit.Graph;
4using CascadeIDE.Models;
5
6namespace CascadeIDE.Cockpit.Graph.Layout;
7
8/// <summary>
9/// Укладка control-flow: главный поток по оси (адаптивно горизонт/вертикаль),
10/// побочная ось — параллели уровня из BFS от якоря.
11/// </summary>
12public sealed class ControlFlowGraphLayoutEngine : IGraphLayoutEngine
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 new GraphLayoutScene
24 {
25 Nodes = [],
26 Edges = [],
27 Legend = [],
28 LegendColumnLeft = width,
29 LegendPlacement = GraphLegendBlockPlacement.BesideGraph,
30 LegendBlockTopY = 0
31 };
32
33 var anchor = doc.Nodes.FirstOrDefault(n => string.Equals(n.Kind, "anchor", StringComparison.OrdinalIgnoreCase))
34 ?? doc.Nodes.FirstOrDefault(n => n.Id.Equals("n0", StringComparison.OrdinalIgnoreCase))
35 ?? (doc.Nodes.Count > 0 ? doc.Nodes[0] : null);
36 if (anchor is null)
37 return new GraphLayoutScene
38 {
39 Nodes = [],
40 Edges = [],
41 Legend = [],
42 LegendColumnLeft = width,
43 LegendPlacement = GraphLegendBlockPlacement.BesideGraph,
44 LegendBlockTopY = 0
45 };
46
47 var nodeById = doc.Nodes.ToDictionary(n => n.Id, StringComparer.OrdinalIgnoreCase);
48 var outgoing = BuildOutgoing(doc.Edges);
49 var depthById = BuildDepthMap(anchor.Id, outgoing);
50
51 // Узлы, недостижимые от anchor, ставим в хвост (последние уровни), чтобы не терялись.
52 var maxDepth = depthById.Count == 0 ? 0 : depthById.Values.Max();
53 foreach (var n in doc.Nodes)
54 {
55 if (depthById.ContainsKey(n.Id))
56 continue;
57 maxDepth++;
58 depthById[n.Id] = maxDepth;
59 }
60
61 var levels = depthById
62 .GroupBy(kv => kv.Value)
63 .OrderBy(g => g.Key)
64 .ToDictionary(g => g.Key, g => g.Select(kv => kv.Key).ToList());
65
66 var topPadding = GraphControlFlowLayoutMetrics.TopPadding;
67 var bottomPadding = GraphControlFlowLayoutMetrics.BottomPadding;
68 var sidePadding = GraphControlFlowLayoutMetrics.SidePadding;
69 var legendGap = GraphControlFlowLayoutMetrics.LegendGap;
70
71 static bool IsExitStep(GraphNode n) =>
72 string.Equals(n.Kind, "exit_step", StringComparison.OrdinalIgnoreCase);
73
74 static bool IsConditionStep(GraphNode n) =>
75 string.Equals(n.Kind, "condition_step", StringComparison.OrdinalIgnoreCase);
76
77 var compactLegend = detailLevel == CodeNavigationMapDetailLevel.Glance;
78 var hasLegendRows = detailLevel == CodeNavigationMapDetailLevel.Inspect && doc.Nodes.Any(static n =>
79 !IsExitStep(n)
80 && n.LegendIndex is > 0
81 && !string.IsNullOrWhiteSpace(n.LegendText));
82 var showNodeLegendGlyphs = doc.Nodes.Any(static n =>
83 !IsExitStep(n) && n.LegendIndex is > 0);
84 var showLegendConditionKey = !compactLegend && doc.Nodes.Any(IsConditionStep);
85 var showLegendReturnKey = !compactLegend && doc.Nodes.Any(IsExitStep);
86 var showLegendExceptionFlowKey = !compactLegend && doc.Nodes.Any(static n =>
87 string.Equals(n.Kind, "handler_step", StringComparison.OrdinalIgnoreCase));
88 ComputeEdgeStyleLegend(
89 doc.Edges,
90 out var edgeStyleLegendKey,
91 out var edgeStyleLegendRowCount);
92 var showLegendEdgeStyleKey = !compactLegend && edgeStyleLegendKey;
93 var useLegendColumn = hasLegendRows || showLegendConditionKey || showLegendReturnKey
94 || showLegendExceptionFlowKey || showLegendEdgeStyleKey;
95 var legendRowsPreview = hasLegendRows
96 ? doc.Nodes
97 .Where(n =>
98 !IsExitStep(n)
99 && n.LegendIndex is > 0
100 && !string.IsNullOrWhiteSpace(n.LegendText))
101 .OrderBy(n => n.LegendIndex.GetValueOrDefault())
102 .Select(n => new GraphLegendEntry { Index = n.LegendIndex!.Value, Text = n.LegendText!.Trim() })
103 .ToList()
104 : new List<GraphLegendEntry>();
105
106 var contentLegendNeed = EstimateLegendColumnContentWidth(
107 legendRowsPreview,
108 showLegendReturnKey,
109 showLegendConditionKey,
110 showLegendExceptionFlowKey,
111 showLegendEdgeStyleKey);
112 var fallbackFraction = width * GraphControlFlowLayoutMetrics.LegendReserveWidthFraction;
113 var legendReserveCap = GraphControlFlowLayoutMetrics.ResolveLegendReserveCap(width);
114 var legendReserveLo = Math.Min(GraphControlFlowLayoutMetrics.LegendReserveMin, legendReserveCap);
115 var firstLegendReserve = useLegendColumn
116 ? Math.Clamp(
117 Math.Max(fallbackFraction, contentLegendNeed),
118 legendReserveLo,
119 legendReserveCap)
120 : 0;
121 IReadOnlyList<GraphLegendEntry> legendRows = legendRowsPreview;
122
123 GraphLayoutScene BuildFor(double heightForY, double legendResForWidth)
124 {
125 var graphWidth = Math.Max(
126 GraphControlFlowLayoutMetrics.MinGraphWidth,
127 width - legendResForWidth - (useLegendColumn ? legendGap : 0));
128
129 var levelKeys = Math.Max(1, levels.Count);
130 var maxBreadth = levels.Count == 0 ? 1 : levels.Values.Max(static ids => ids.Count);
131 var mainAxis = controlFlowMainAxisOverride
132 ?? GraphControlFlowLayoutMetrics.ChooseMainAxis(graphWidth, heightForY, levelKeys, maxBreadth);
133 var horizontal = mainAxis == GraphControlFlowMainAxis.Horizontal;
134
135 var innerH = heightForY - topPadding - bottomPadding;
136 var innerMain = horizontal ? graphWidth - 2 * sidePadding : innerH;
137 var bandCrossSize = horizontal
138 ? GraphControlFlowLayoutMetrics.ResolveReadableBandWidth(innerH)
139 : GraphControlFlowLayoutMetrics.ResolveReadableBandWidth(graphWidth);
140 var bandCrossStart = horizontal
141 ? topPadding + (innerH - bandCrossSize) * 0.5
142 : (graphWidth - bandCrossSize) * 0.5;
143 var centerCross = bandCrossStart + bandCrossSize * 0.5;
144 var labelCharBudget = GraphControlFlowLayoutMetrics.ResolveLabelCharBudget(bandCrossSize);
145 if (layoutOptions.IsDense)
146 labelCharBudget = Math.Max(GraphControlFlowLayoutMetrics.LabelCharBudgetMin, labelCharBudget - 4);
147
148 var slotCount = Math.Max(1, levelKeys - 1);
149 var rawMainStep = innerMain / slotCount;
150 var minMainStep = GraphControlFlowLayoutMetrics.MinVerticalStepForLevelCount(levelKeys);
151 var maxMainStep = Math.Min(
152 GraphControlFlowLayoutMetrics.MaxReadableVerticalStepCap,
153 Math.Max(GraphControlFlowLayoutMetrics.MaxReadableVerticalStep, rawMainStep));
154 var mainStep = Math.Clamp(rawMainStep, minMainStep, maxMainStep);
155 var mainSpan = Math.Max(0, levelKeys - 1) * mainStep;
156 var mainStart = horizontal
157 ? sidePadding + ((graphWidth - 2 * sidePadding) - mainSpan) * 0.5
158 : topPadding + (innerH - mainSpan) * 0.5;
159
160 var radiusMul = Math.Clamp(
161 mainStep / GraphControlFlowLayoutMetrics.RefVerticalStep,
162 GraphControlFlowLayoutMetrics.RadiusScaleMin,
163 GraphControlFlowLayoutMetrics.RadiusScaleMax);
164 var horizontalRadiusScale = Math.Clamp(
165 bandCrossSize / GraphControlFlowLayoutMetrics.MaxReadableBandWidth,
166 GraphControlFlowLayoutMetrics.HorizontalRadiusScaleMin,
167 1.0);
168 radiusMul *= horizontalRadiusScale;
169 if (layoutOptions.IsDense)
170 radiusMul *= 0.92;
171 var sideLabelFontPx = GraphControlFlowLayoutMetrics.ResolveSideLabelFontSize(bandCrossSize, mainStep);
172 var anchorR = GraphControlFlowLayoutMetrics.AnchorRadiusBase * radiusMul;
173 var nodeR = GraphControlFlowLayoutMetrics.NodeRadiusBase * radiusMul;
174
175 var minCross = bandCrossStart + sidePadding + nodeR;
176 var maxCross = Math.Max(minCross, bandCrossStart + bandCrossSize - sidePadding - nodeR);
177
178 var idToCenter = new Dictionary<string, Point>(StringComparer.OrdinalIgnoreCase);
179 var idToRadius = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
180 var nodeLayouts = new List<GraphLayoutNode>(doc.Nodes.Count);
181
182 foreach (var (depth, ids) in levels)
183 {
184 var orderedIds = ids
185 .OrderBy(id => string.Equals(id, anchor.Id, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
186 .ThenBy(id => id, StringComparer.OrdinalIgnoreCase)
187 .ToList();
188
189 var count = orderedIds.Count;
190 var mainPos = mainStart + depth * mainStep;
191
192 for (var i = 0; i < count; i++)
193 {
194 var id = orderedIds[i];
195 if (!nodeById.TryGetValue(id, out var n))
196 continue;
197
198 var crossPos = count == 1
199 ? centerCross
200 : minCross + (maxCross - minCross) * i / (count - 1);
201 var radius = string.Equals(id, anchor.Id, StringComparison.OrdinalIgnoreCase) ? anchorR : nodeR;
202 var pt = horizontal
203 ? new Point(mainPos, crossPos)
204 : new Point(crossPos, mainPos);
205 idToCenter[id] = pt;
206 idToRadius[id] = radius;
207 var isAnchor = string.Equals(n.Id, anchor.Id, StringComparison.OrdinalIgnoreCase);
208 var shape = !isAnchor && string.Equals(n.Kind, "condition_step", StringComparison.OrdinalIgnoreCase)
209 ? GraphNodeShape.Condition
210 : GraphNodeShape.Circle;
211 nodeLayouts.Add(new GraphLayoutNode
212 {
213 Id = n.Id,
214 Kind = n.Kind,
215 FullPath = n.Path,
216 Label = TruncateLabel(n.Label, labelCharBudget),
217 Center = pt,
218 Radius = radius,
219 IsAnchor = isAnchor,
220 Shape = shape,
221 LegendIndex = n.LegendIndex,
222 LegendLine = n.LegendText,
223 LineStart = n.LineStart,
224 LineEnd = n.LineEnd,
225 LoopGroupId = n.LoopGroupId
226 });
227 }
228 }
229
230 var edgeLayouts = new List<GraphLayoutEdge>(doc.Edges.Count);
231 foreach (var e in doc.Edges)
232 {
233 if (!idToCenter.TryGetValue(e.FromId, out var from))
234 continue;
235 if (!idToCenter.TryGetValue(e.ToId, out var to))
236 continue;
237
238 edgeLayouts.Add(new GraphLayoutEdge
239 {
240 FromNodeId = e.FromId,
241 ToNodeId = e.ToId,
242 From = from,
243 To = to,
244 ToRadius = idToRadius.TryGetValue(e.ToId, out var toR) ? toR : nodeR,
245 Kind = e.Kind,
246 RelationKind = e.RelationKind,
247 EdgeProvenance = e.EdgeProvenance
248 });
249 }
250
251 // Колонка легенды: сразу справа от фактического «чернильного» правого края узлов, а не от правой границы полосы.
252 var legendColumnLeft = width;
253 if (useLegendColumn)
254 {
255 if (nodeLayouts.Count > 0)
256 {
257 var inkSlack = GraphControlFlowLayoutMetrics.BesideLegendInkSlack;
258 var minClear = Math.Max(legendGap, GraphControlFlowLayoutMetrics.LegendBesideMinClearance);
259 var maxInkRight = 0.0;
260 foreach (var n in nodeLayouts)
261 maxInkRight = Math.Max(maxInkRight, n.Center.X + n.Radius + inkSlack);
262 legendColumnLeft = maxInkRight + minClear;
263 }
264 else
265 {
266 var minClear = Math.Max(legendGap, GraphControlFlowLayoutMetrics.LegendBesideMinClearance);
267 legendColumnLeft = horizontal
268 ? graphWidth - sidePadding * 2 + minClear
269 : bandCrossStart + bandCrossSize + minClear;
270 }
271 }
272
273 return new GraphLayoutScene
274 {
275 Nodes = nodeLayouts,
276 Edges = edgeLayouts,
277 Legend = legendRows,
278 UseLegendColumn = useLegendColumn,
279 ShowLegendConditionKey = showLegendConditionKey,
280 ShowLegendReturnKey = showLegendReturnKey,
281 ShowLegendExceptionFlowKey = showLegendExceptionFlowKey,
282 ShowLegendEdgeStyleKey = showLegendEdgeStyleKey,
283 LegendColumnLeft = legendColumnLeft,
284 LegendPlacement = GraphLegendBlockPlacement.BesideGraph,
285 LegendBlockTopY = 0,
286 SideLabelFontSizePx = sideLabelFontPx,
287 ShowNodeLegendGlyphs = showNodeLegendGlyphs,
288 ControlFlowMainAxis = mainAxis
289 };
290 }
291
292 var sceneBeside = BuildFor(height, firstLegendReserve);
293 if (!useLegendColumn)
294 return sceneBeside;
295
296 var textRoomBeside = width - sceneBeside.LegendColumnLeft - 4;
297 // Колонка у правого края вьюпорта — соседняя невозможна без наложения на граф/обрезки.
298 var besideUnusable = sceneBeside.LegendColumnLeft + 8 >= width;
299 var needBelow = besideUnusable
300 || textRoomBeside < GraphControlFlowLayoutMetrics.LegendSideColumnMinTextWidth
301 || contentLegendNeed > textRoomBeside + 0.5;
302 if (!needBelow)
303 return sceneBeside;
304
305 var hasShapeKeyRows = showLegendReturnKey || showLegendConditionKey || showLegendExceptionFlowKey;
306 var capEst = sceneBeside.SideLabelFontSizePx ?? 11.0;
307 var estimatedLegendH = GraphControlFlowLayoutMetrics.EstimateLegendBlockHeight(
308 legendRows.Count,
309 hasShapeKeyRows,
310 edgeStyleLegendRowCount,
311 capEst);
312 var belowGap = GraphControlFlowLayoutMetrics.LegendBelowBlockGap;
313 var graphH = height - estimatedLegendH - belowGap;
314 if (graphH < GraphControlFlowLayoutMetrics.MinGraphHeightForBelowLegend)
315 return sceneBeside;
316
317 var sceneBelow = BuildFor(graphH, 0);
318 if (sceneBelow.Nodes.Count == 0)
319 return sceneBeside;
320
321 var maxBottom = 0.0;
322 foreach (var n in sceneBelow.Nodes)
323 maxBottom = Math.Max(maxBottom, n.Center.Y + n.Radius);
324 var legendTopY = maxBottom + belowGap;
325 if (legendTopY + estimatedLegendH > height + 1.0)
326 return sceneBeside;
327
328 return new GraphLayoutScene
329 {
330 Nodes = sceneBelow.Nodes,
331 Edges = sceneBelow.Edges,
332 Legend = sceneBelow.Legend,
333 UseLegendColumn = useLegendColumn,
334 ShowLegendConditionKey = showLegendConditionKey,
335 ShowLegendReturnKey = showLegendReturnKey,
336 ShowLegendExceptionFlowKey = showLegendExceptionFlowKey,
337 ShowLegendEdgeStyleKey = showLegendEdgeStyleKey,
338 LegendColumnLeft = sidePadding,
339 LegendPlacement = GraphLegendBlockPlacement.BelowGraph,
340 LegendBlockTopY = legendTopY,
341 SideLabelFontSizePx = sceneBelow.SideLabelFontSizePx,
342 ShowNodeLegendGlyphs = showNodeLegendGlyphs,
343 ControlFlowMainAxis = sceneBelow.ControlFlowMainAxis
344 };
345 }
346
347 /// <summary>Оценка минимальной ширины колонки легенды (индекс + текст + блок ключей фигур).</summary>
348 private static double EstimateLegendColumnContentWidth(
349 IReadOnlyList<GraphLegendEntry> rows,
350 bool showReturnKey,
351 bool showConditionKey,
352 bool showExceptionKey,
353 bool showEdgeStyleKey = false)
354 {
355 const double charPx = 6.15;
356 const double idxPad = 36;
357 const double margin = 20;
358 var maxLine = 0;
359 foreach (var row in rows)
360 maxLine = Math.Max(maxLine, Math.Min(row.Text.Length, 220));
361
362 var body = idxPad + maxLine * charPx;
363 var keys = 0d;
364 if (showReturnKey || showConditionKey || showExceptionKey)
365 keys = idxPad + 96;
366 if (showEdgeStyleKey)
367 keys = Math.Max(keys, idxPad + 220);
368
369 return Math.Max(body, keys) + margin;
370 }
371
372 /// <summary>Блок «стили рёбер» в легенде: только если в графе есть нестандартные рёбра (пунктир, цикл).</summary>
373 private static void ComputeEdgeStyleLegend(
374 IReadOnlyList<GraphEdge> edges,
375 out bool show,
376 out int rowCount)
377 {
378 static bool KindContains(string? kind, string needle) =>
379 !string.IsNullOrEmpty(kind) && kind.Contains(needle, StringComparison.OrdinalIgnoreCase);
380
381 var needsNonSolidLegend = edges.Any(e =>
382 KindContains(e.Kind, "conditional")
383 || KindContains(e.Kind, "exception")
384 || KindContains(e.Kind, "multibranch")
385 || KindContains(e.Kind, "loop"));
386 if (!needsNonSolidLegend)
387 {
388 show = false;
389 rowCount = 0;
390 return;
391 }
392
393 show = true;
394 rowCount = 1; // сплошная — контраст с пунктиром
395 if (edges.Any(e => KindContains(e.Kind, "conditional") || KindContains(e.Kind, "exception")))
396 rowCount++;
397 if (edges.Any(e => KindContains(e.Kind, "multibranch")))
398 rowCount++;
399 if (edges.Any(e => KindContains(e.Kind, "loop")))
400 rowCount++;
401 }
402
403 private static Dictionary<string, List<string>> BuildOutgoing(IReadOnlyList<GraphEdge> edges)
404 {
405 var outgoing = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
406 foreach (var e in edges)
407 {
408 if (!outgoing.TryGetValue(e.FromId, out var list))
409 {
410 list = [];
411 outgoing[e.FromId] = list;
412 }
413
414 list.Add(e.ToId);
415 }
416
417 return outgoing;
418 }
419
420 private static Dictionary<string, int> BuildDepthMap(string anchorId, Dictionary<string, List<string>> outgoing)
421 {
422 var depthById = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
423 {
424 [anchorId] = 0
425 };
426 var queue = new Queue<string>();
427 queue.Enqueue(anchorId);
428
429 while (queue.Count > 0)
430 {
431 var id = queue.Dequeue();
432 var depth = depthById[id];
433 if (!outgoing.TryGetValue(id, out var next))
434 continue;
435
436 foreach (var to in next)
437 {
438 if (depthById.ContainsKey(to))
439 continue;
440 depthById[to] = depth + 1;
441 queue.Enqueue(to);
442 }
443 }
444
445 return depthById;
446 }
447
448 private static string TruncateLabel(string label, int maxChars)
449 {
450 if (label.Length <= maxChars)
451 return label;
452 var take = Math.Max(1, maxChars - 1);
453 return label[..take] + "…";
454 }
455}
456
View only · write via MCP/CIDE