📄 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 static readonly SymbolDisplayFormat LabelFormat = new(
        typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes,
        genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
        miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes
            | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
    );

    public static GraphScene Build(CallGraphAnalysis analysis, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(analysis);
        cancellationToken.ThrowIfCancellationRequested();
        var nodes = new List<GraphNode>();
        var bodies = new List<PhysicsNode>();
        var links = new List<Link>();
        var loopbacks = new HashSet<int>();
        CreateNode(analysis.Origin, -1, 210, false);
        Expand(analysis.CalledFrom, true);
        Expand(analysis.CallsTo, false);

        var simulation = new PhysicsLayout(bodies);
        simulation.Settle(cancellationToken);
        for (var i = 0; i < nodes.Count; i++)
            nodes[i].Location = simulation.Positions[i] - new Point(nodes[i].Width / 2, nodes[i].Height / 2);

        var edges = new List<GraphEdge>();
        foreach (var link in links)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var source = link.Incoming ? link.Child : link.Parent;
            var target = link.Incoming ? link.Parent : link.Child;
            var loop = ReferenceEquals(source, target);
            var thickness = CalculateThickness(link.Call);
            var start = loop
                ? new Point(source.Bounds.Right + thickness / 2, source.Bounds.Center.Y - source.Height / 4)
                : Anchor(source, target.Bounds.Center);
            var end = loop
                ? new Point(target.Bounds.Right + thickness / 2, target.Bounds.Center.Y + target.Height / 4)
                : Anchor(target, source.Bounds.Center);
            edges.Add(new GraphEdge(source, target, start, end, thickness, loop));
        }
        return new GraphScene(nodes.AsReadOnly(), edges.AsReadOnly());

        GraphNode CreateNode(ISymbol symbol, int parent, double radius, bool cycle)
        {
            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,
                FormatLabel(symbol),
                sourceLink,
                new Size(200, 72),
                parent < 0,
                cycle
            );
            nodes.Add(node);
            bodies.Add(new PhysicsNode(parent, radius));
            return node;
        }

        void Expand(IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> map, bool incoming)
        {
            var path = new HashSet<ISymbol>(SymbolEqualityComparer.Default) { analysis.Origin };
            var pending = new Stack<Frame>();
            pending.Push(new Frame(nodes[0], 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, incoming));
                    continue;
                }
                var cycle = path.Contains(call.Target);
                var node = CreateNode(call.Target, frame.Node.Id, CalculateEdgeLength(call), cycle);
                links.Add(new Link(frame.Node, node, call, incoming));
                if (!cycle)
                {
                    path.Add(call.Target);
                    pending.Push(new Frame(node, GetCalls(call.Target)));
                }
            }
            IImmutableList<CallAnalysis> GetCalls(ISymbol symbol) =>
                map.TryGetValue(symbol, out var calls) ? calls : ImmutableList<CallAnalysis>.Empty;
        }
    }

    /// <summary>The incoming call sets the target occurrence's exclusion radius.</summary>
    public static double CalculateEdgeLength(CallAnalysis call) =>
        210 + KindWeight(call.LinkKind) * 180 + ReachWeight(call.Reach) * 140;

    public static double CalculateThickness(CallAnalysis call) =>
        1.5 + KindWeight(call.LinkKind) * 1.25 + ReachWeight(call.Reach) * 0.75;

    private static Point Anchor(GraphNode node, Point toward)
    {
        var delta = new Vector(toward.X - node.Bounds.Center.X, toward.Y - node.Bounds.Center.Y);
        var scale = Math.Min(
            node.Width / 2 / Math.Max(Math.Abs(delta.X), 1e-10),
            node.Height / 2 / Math.Max(Math.Abs(delta.Y), 1e-10)
        );
        return node.Bounds.Center + delta * scale;
    }

    private static string FormatLabel(ISymbol symbol)
    {
        var type = symbol.ContainingType.ToDisplayString(LabelFormat);
        return symbol switch
        {
            IMethodSymbol { MethodKind: MethodKind.Constructor or MethodKind.StaticConstructor } => "new "
                + type
                + "()",
            IMethodSymbol => type + "." + symbol.ToDisplayString(LabelFormat) + "()",
            IPropertySymbol { IsIndexer: true } => type + ".this[]",
            _ => type + "." + symbol.ToDisplayString(LabelFormat),
        };
    }

    private static int KindWeight(LinkKind kind) =>
        kind switch
        {
            LinkKind.Direct => 0,
            LinkKind.Abstract => 1,
            LinkKind.Event => 2,
            _ => throw new ArgumentOutOfRangeException(nameof(kind)),
        };

    private static int ReachWeight(CallReach reach) =>
        reach switch
        {
            CallReach.SameType => 0,
            CallReach.SameNamespace => 1,
            CallReach.SameAssembly => 2,
            CallReach.CrossAssembly => 3,
            _ => throw new ArgumentOutOfRangeException(nameof(reach)),
        };

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

    private sealed record Link(GraphNode Parent, GraphNode Child, CallAnalysis Call, bool Incoming);
}