📄 ui/src/Graph/GraphScene.cs
using System;
using System.Collections.Generic;
using Avalonia;
using Microsoft.CodeAnalysis;

namespace Cfa.Ui.Graph;

public sealed record GraphScene(IReadOnlyList<GraphNode> Nodes, IReadOnlyList<GraphEdge> Edges)
{
    /// <summary>Conservative viewport bounds including connection curves, strokes, and arrowheads.</summary>
    public Rect Bounds { get; } = CalculateBounds(Nodes, Edges);

    private static Rect CalculateBounds(IReadOnlyList<GraphNode> nodes, IReadOnlyList<GraphEdge> edges)
    {
        Rect? bounds = null;
        foreach (var node in nodes)
            bounds = bounds?.Union(node.Bounds) ?? node.Bounds;
        foreach (var edge in edges)
        {
            // A cubic curve lies within its control-point hull; 15 also covers the 13-by-6 arrowhead.
            var curve = Envelope(edge.Start, edge.End)
                .Union(Envelope(edge.Control1, edge.Control2))
                .Inflate(15 + edge.Thickness / 2);
            bounds = bounds?.Union(curve) ?? curve;
        }
        return bounds ?? default;

        static Rect Envelope(Point first, Point second) =>
            new(
                Math.Min(first.X, second.X),
                Math.Min(first.Y, second.Y),
                Math.Abs(first.X - second.X),
                Math.Abs(first.Y - second.Y)
            );
    }
}

public sealed class GraphNode
{
    internal GraphNode(
        int id,
        ISymbol symbol,
        string label,
        string? sourceLink,
        Size size,
        bool isOrigin,
        bool isCycleReference
    )
    {
        Id = id;
        Symbol = symbol;
        Label = label;
        SourceLink = sourceLink;
        Width = size.Width;
        Height = size.Height;
        IsOrigin = isOrigin;
        IsCycleReference = isCycleReference;
    }

    public int Id { get; }
    public ISymbol Symbol { get; }
    public string Label { get; }
    public string? SourceLink { get; }
    public Point Location { get; internal set; }
    public double Width { get; }
    public double Height { get; }
    public bool IsOrigin { get; }
    public bool IsCycleReference { get; }
    public Rect Bounds => new(Location, new Size(Width, Height));
}

public sealed record GraphEdge(
    GraphNode Source,
    GraphNode Target,
    Point Start,
    Point End,
    double Thickness,
    bool IsLoopback
)
{
    /// <summary>Cubic control points shared by connection rendering and viewport fitting.</summary>
    public Point Control1 =>
        IsLoopback
            ? new Point(Start.X + 100, Start.Y - 100)
            : new Point(Start.X + Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), Start.Y);

    public Point Control2 =>
        IsLoopback
            ? new Point(End.X - 100, End.Y - 100)
            : new Point(End.X - Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), End.Y);
}