📄 ui/src/Graph/GraphLayout.cs
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using Avalonia;
using Microsoft.CodeAnalysis;

namespace Cfa.Ui.Graph;

public static class GraphLayout
{
    private const double SiblingGap = 32;
    private static readonly SymbolDisplayFormat LabelFormat = new(
        typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly,
        genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
        memberOptions: SymbolDisplayMemberOptions.IncludeParameters,
        parameterOptions: SymbolDisplayParameterOptions.IncludeType
            | SymbolDisplayParameterOptions.IncludeName
            | SymbolDisplayParameterOptions.IncludeParamsRefOut,
        miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes
            | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
            | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier
    );

    public static GraphScene Build(CallGraphAnalysis analysis, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(analysis);
        cancellationToken.ThrowIfCancellationRequested();

        var nodes = new List<GraphNode>();
        var vertices = new List<Vertex>();
        var links = new List<Link>();
        var loopbacks = new HashSet<int>();
        var origin = CreateNode(analysis.Origin, new Size(280, 80), true, false);
        origin.Location = new Point(-origin.Width / 2, -origin.Height / 2);
        var incoming = Expand(analysis.CalledFrom, -1);
        var outgoing = Expand(analysis.CallsTo, 1);

        for (var i = vertices.Count - 1; i >= 0; i--)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var vertex = vertices[i];
            vertex.SubtreeHeight = Math.Max(vertex.Node.Height, ChildrenHeight(vertex.Children));
        }

        Position(incoming, -1);
        Position(outgoing, 1);
        var edges = new List<GraphEdge>(links.Count);
        foreach (var link in links)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var source = link.Direction < 0 ? link.Child : link.Parent;
            var target = link.Direction < 0 ? link.Parent : link.Child;
            var loopback = ReferenceEquals(source, target);
            var start = loopback
                ? new Point(source.Bounds.Right, source.Bounds.Center.Y - source.Height / 4)
                : new Point(source.Bounds.Right, source.Bounds.Center.Y);
            var end = loopback
                ? new Point(target.Bounds.Right, target.Bounds.Center.Y + target.Height / 4)
                : new Point(target.Bounds.Left, target.Bounds.Center.Y);
            edges.Add(new GraphEdge(source, target, start, end, CalculateThickness(link.Call), loopback));
        }

        return new GraphScene(nodes.AsReadOnly(), edges.AsReadOnly());

        GraphNode CreateNode(ISymbol symbol, Size size, bool isOrigin, bool isCycleReference)
        {
            var syntax = symbol.DeclaringSyntaxReferences.Length > 0 ? symbol.DeclaringSyntaxReferences[0] : null;
            string? sourceLink = null;
            if (syntax is not null)
            {
                var start = syntax.SyntaxTree.GetLineSpan(syntax.Span, cancellationToken).StartLinePosition;
                sourceLink = $"file://{syntax.SyntaxTree.FilePath}#{start.Line + 1}:{start.Character + 1}";
            }

            var node = new GraphNode(
                nodes.Count,
                symbol,
                symbol.ToDisplayString(LabelFormat),
                sourceLink,
                size,
                isOrigin,
                isCycleReference
            );
            nodes.Add(node);
            return node;
        }

        List<Vertex> Expand(IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> map, int direction)
        {
            var roots = new List<Vertex>();
            var path = new HashSet<ISymbol>(SymbolEqualityComparer.Default) { analysis.Origin };
            var pending = new Stack<Frame>();
            pending.Push(new Frame(origin, roots, GetCalls(analysis.Origin)));
            while (pending.TryPeek(out var frame))
            {
                cancellationToken.ThrowIfCancellationRequested();
                if (frame.Index == frame.Calls.Count)
                {
                    pending.Pop();
                    path.Remove(frame.Node.Symbol);
                    continue;
                }

                var call = frame.Calls[frame.Index++];
                if (SymbolEqualityComparer.Default.Equals(frame.Node.Symbol, call.Target))
                {
                    if (loopbacks.Add(frame.Node.Id))
                        links.Add(new Link(frame.Node, frame.Node, call, direction));
                    continue;
                }

                var cycle = path.Contains(call.Target);
                var appearances = analysis.Appearances.TryGetValue(call.Target, out var count) ? count : 1;
                var node = CreateNode(call.Target, CalculateNodeSize(call, appearances), false, cycle);
                var vertex = new Vertex(node, call);
                vertices.Add(vertex);
                frame.Children.Add(vertex);
                links.Add(new Link(frame.Node, node, call, direction));
                if (!cycle)
                {
                    path.Add(call.Target);
                    pending.Push(new Frame(node, vertex.Children, GetCalls(call.Target)));
                }
            }

            return roots;

            IImmutableList<CallAnalysis> GetCalls(ISymbol symbol) =>
                map.TryGetValue(symbol, out var calls) ? calls : ImmutableList<CallAnalysis>.Empty;
        }

        void Position(List<Vertex> roots, int direction)
        {
            var pending = new Queue<(GraphNode Parent, List<Vertex> Children)>();
            pending.Enqueue((origin, roots));
            while (pending.TryDequeue(out var group))
            {
                cancellationToken.ThrowIfCancellationRequested();
                var top = group.Parent.Bounds.Center.Y - ChildrenHeight(group.Children) / 2;
                foreach (var child in group.Children)
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    var centerY = top + child.SubtreeHeight / 2;
                    var offsetY = centerY - group.Parent.Bounds.Center.Y;
                    var length = CalculateEdgeLength(child.Call);
                    // Compact helper branches follow a semicircle; broad subtrees get extra outward space.
                    var projectedY = Math.Min(Math.Abs(offsetY), length * 0.85);
                    var gap = Math.Sqrt(length * length - projectedY * projectedY);
                    gap += Math.Min(160, Math.Max(0, child.SubtreeHeight - child.Node.Height) * 0.08);
                    var x =
                        direction > 0
                            ? group.Parent.Bounds.Right + gap
                            : group.Parent.Bounds.Left - gap - child.Node.Width;
                    child.Node.Location = new Point(x, centerY - child.Node.Height / 2);
                    top += child.SubtreeHeight + SiblingGap;
                    pending.Enqueue((child.Node, child.Children));
                }
            }
        }

        double ChildrenHeight(List<Vertex> children)
        {
            var height = 0d;
            foreach (var child in children)
            {
                cancellationToken.ThrowIfCancellationRequested();
                height += child.SubtreeHeight;
            }
            return height + Math.Max(0, children.Count - 1) * SiblingGap;
        }
    }

    /// <summary>Preferred anchor distance before space for broad branches is added.</summary>
    public static double CalculateEdgeLength(CallAnalysis call) =>
        90
        + (
            call.LinkKind switch
            {
                LinkKind.Abstract => 0,
                LinkKind.Direct => 24,
                LinkKind.Event => 48,
                _ => throw new ArgumentOutOfRangeException(nameof(call)),
            }
        )
        + (
            call.Reach switch
            {
                CallReach.SameType => 0,
                CallReach.SameNamespace => 36,
                CallReach.SameAssembly => 72,
                CallReach.CrossAssembly => 108,
                _ => throw new ArgumentOutOfRangeException(nameof(call)),
            }
        );

    /// <summary>Events and wider reach add prominence; repeats shrink within readable bounds. Origin size is independent.</summary>
    public static Size CalculateNodeSize(CallAnalysis call, uint appearances)
    {
        var labelWidth = Math.Clamp(call.Target.ToDisplayString(LabelFormat).Length * 7.5 + 36, 180, 360);
        var prominence =
            1
            + (call.LinkKind == LinkKind.Event ? 0.3 : 0)
            + (
                call.Reach switch
                {
                    CallReach.SameType => 0,
                    CallReach.SameNamespace => 0.06,
                    CallReach.SameAssembly => 0.12,
                    CallReach.CrossAssembly => 0.2,
                    _ => throw new ArgumentOutOfRangeException(nameof(call)),
                }
            );
        var scale = 1 / (1 + Math.Log2(Math.Max(1u, appearances)) * 0.12);
        return new Size(
            Math.Clamp(labelWidth * prominence * scale, 132, 480),
            Math.Clamp(64 * prominence * scale, 44, 104)
        );
    }

    public static double CalculateThickness(CallAnalysis call) =>
        call.LinkKind switch
        {
            LinkKind.Abstract => 1.5,
            LinkKind.Direct => 2.5,
            LinkKind.Event => 3.5,
            _ => throw new ArgumentOutOfRangeException(nameof(call)),
        };

    private sealed class Vertex(GraphNode node, CallAnalysis call)
    {
        public GraphNode Node { get; } = node;
        public CallAnalysis Call { get; } = call;
        public List<Vertex> Children { get; } = [];
        public double SubtreeHeight { get; set; }
    }

    private sealed class Frame(GraphNode node, List<Vertex> children, IImmutableList<CallAnalysis> calls)
    {
        public GraphNode Node { get; } = node;
        public List<Vertex> Children { get; } = children;
        public IImmutableList<CallAnalysis> Calls { get; } = calls;
        public int Index { get; set; }
    }

    private sealed record Link(GraphNode Parent, GraphNode Child, CallAnalysis Call, int Direction);
}