ui/src/Graph/EdgeAvoidance.cs
+156
-0
diff --git a/ui/src/Graph/EdgeAvoidance.cs b/ui/src/Graph/EdgeAvoidance.cs
new file mode 100644
index 0000000..b4f17f4
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Avalonia;
namespace Cfa.Ui.Graph;
internal static class EdgeAvoidance
{
public static void Apply(
PhysicsNode[] nodes,
Point[] positions,
Vector[] forces,
PhysicsOptions options,
CancellationToken token
)
{
if (options.EdgeCrossingStrength == 0 && options.EdgeNodeStrength == 0)
return;
var cellSize = Math.Max(240, options.EdgeClearance * 2);
var cells = new Dictionary<(int X, int Y), List<int>>();
for (var i = 1; i < nodes.Length; i++)
{
token.ThrowIfCancellationRequested();
var start = positions[nodes[i].Parent];
var end = positions[i];
foreach (var key in Keys(start, end, options.EdgeClearance, cellSize))
{
if (!cells.TryGetValue(key, out var edges))
cells[key] = edges = [];
edges.Add(i);
}
}
var pairs = new HashSet<(int, int)>();
for (var i = 1; i < nodes.Length && options.EdgeCrossingStrength > 0; i++)
{
token.ThrowIfCancellationRequested();
var a = nodes[i].Parent;
foreach (var key in Keys(positions[a], positions[i], options.EdgeClearance, cellSize))
{
foreach (var j in cells[key])
{
if (j <= i || !pairs.Add((i, j)))
continue;
var b = nodes[j].Parent;
if (a == b || a == j || b == i)
continue;
var crossing = Crosses(positions[a], positions[i], positions[b], positions[j]);
var bestDistance = double.PositiveInfinity;
var endpoint = -1;
var edgeStart = -1;
var edgeEnd = -1;
var fraction = 0d;
var offset = default(Vector);
Consider(a, b, j);
Consider(i, b, j);
Consider(b, a, i);
Consider(j, a, i);
if (!crossing && bestDistance >= options.EdgeClearance)
continue;
var direction =
bestDistance > 1e-8
? offset / bestDistance
: Normal(positions[edgeStart], positions[edgeEnd], endpoint);
// A crossing endpoint moves through the opposing edge, not farther
// into its current side. Near misses instead repel continuously.
var magnitude = crossing
? bestDistance + options.EdgeClearance
: options.EdgeClearance - bestDistance;
var force = direction * (magnitude * options.EdgeCrossingStrength * (crossing ? -1 : 1));
forces[endpoint] += force;
forces[edgeStart] -= force * (1 - fraction);
forces[edgeEnd] -= force * fraction;
void Consider(int point, int first, int second)
{
var t = Fraction(positions[point], positions[first], positions[second]);
var closest = positions[first] + (positions[second] - positions[first]) * t;
var delta = new Vector(positions[point].X - closest.X, positions[point].Y - closest.Y);
if (delta.Length >= bestDistance)
return;
bestDistance = delta.Length;
endpoint = point;
edgeStart = first;
edgeEnd = second;
fraction = t;
offset = delta;
}
}
}
}
if (options.EdgeNodeStrength == 0)
return;
for (var i = 0; i < nodes.Length; i++)
{
token.ThrowIfCancellationRequested();
var key = ((int)Math.Floor(positions[i].X / cellSize), (int)Math.Floor(positions[i].Y / cellSize));
if (!cells.TryGetValue(key, out var edges))
continue;
foreach (var edge in edges)
{
var parent = nodes[edge].Parent;
if (i == edge || i == parent)
continue;
var t = Fraction(positions[i], positions[parent], positions[edge]);
if (t <= 0 || t >= 1)
continue;
var closest = positions[parent] + (positions[edge] - positions[parent]) * t;
var offset = new Vector(positions[i].X - closest.X, positions[i].Y - closest.Y);
var distance = offset.Length;
if (distance >= options.EdgeClearance)
continue;
var direction = distance > 1e-8 ? offset / distance : Normal(positions[parent], positions[edge], i);
var force = direction * ((options.EdgeClearance - distance) * options.EdgeNodeStrength);
forces[i] += force;
forces[parent] -= force * (1 - t);
forces[edge] -= force * t;
}
}
}
private static double Fraction(Point point, Point start, Point end)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
var squared = dx * dx + dy * dy;
return squared < 1e-12 ? 0 : Math.Clamp(((point.X - start.X) * dx + (point.Y - start.Y) * dy) / squared, 0, 1);
}
private static Vector Normal(Point start, Point end, int id)
{
var vector = new Vector(start.Y - end.Y, end.X - start.X);
return vector.Length > 1e-8 ? vector / vector.Length * (id % 2 == 0 ? 1 : -1) : new Vector(0, 1);
}
private static bool Crosses(Point a, Point b, Point c, Point d)
{
static double Side(Point p, Point q, Point r) => (q.X - p.X) * (r.Y - p.Y) - (q.Y - p.Y) * (r.X - p.X);
return Side(a, b, c) * Side(a, b, d) < 0 && Side(c, d, a) * Side(c, d, b) < 0;
}
private static IEnumerable<(int X, int Y)> Keys(Point start, Point end, double margin, double size)
{
for (
var x = (int)Math.Floor((Math.Min(start.X, end.X) - margin) / size);
x <= (int)Math.Floor((Math.Max(start.X, end.X) + margin) / size);
x++
)
for (
var y = (int)Math.Floor((Math.Min(start.Y, end.Y) - margin) / size);
y <= (int)Math.Floor((Math.Max(start.Y, end.Y) + margin) / size);
y++
)
yield return (x, y);
}
}
ui/src/Graph/GraphLayout.cs
+56
-144
diff --git a/ui/src/Graph/GraphLayout.cs b/ui/src/Graph/GraphLayout.cs
index f1f8cb6..0e672f9 100644
@@ -9,58 +9,49 @@ namespace Cfa.Ui.Graph;
public static class GraphLayout
{
private const double SiblingGap = 32;
private static readonly SymbolDisplayFormat LabelFormat = new(
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
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 bodies = new List<PhysicsNode>();
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);
CreateNode(analysis.Origin, -1, 210, false);
Expand(analysis.CalledFrom, true);
Expand(analysis.CallsTo, false);
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));
}
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);
Position(incoming, -1);
Position(outgoing, 1);
var edges = new List<GraphEdge>(links.Count);
var edges = new List<GraphEdge>();
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 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 = loopback
var start = loop
? new Point(source.Bounds.Right + thickness / 2, source.Bounds.Center.Y - source.Height / 4)
: new Point(source.Bounds.Right, source.Bounds.Center.Y);
var end = loopback
: Anchor(source, target.Bounds.Center);
var end = loop
? new Point(target.Bounds.Right + thickness / 2, target.Bounds.Center.Y + target.Height / 4)
: new Point(target.Bounds.Left, target.Bounds.Center.Y);
edges.Add(new GraphEdge(source, target, start, end, thickness, loopback));
: 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, Size size, bool isOrigin, bool isCycleReference)
GraphNode CreateNode(ISymbol symbol, int parent, double radius, bool cycle)
{
var syntax = symbol.DeclaringSyntaxReferences.Length > 0 ? symbol.DeclaringSyntaxReferences[0] : null;
string? sourceLink = null;
@@ -69,26 +60,25 @@ public static class GraphLayout
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,
size,
isOrigin,
isCycleReference
new Size(200, 72),
parent < 0,
cycle
);
nodes.Add(node);
bodies.Add(new PhysicsNode(parent, radius));
return node;
}
List<Vertex> Expand(IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> map, int direction)
void Expand(IImmutableDictionary<ISymbol, IImmutableList<CallAnalysis>> map, bool incoming)
{
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)));
pending.Push(new Frame(nodes[0], GetCalls(analysis.Origin)));
while (pending.TryPeek(out var frame))
{
cancellationToken.ThrowIfCancellationRequested();
@@ -98,121 +88,42 @@ public static class GraphLayout
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));
links.Add(new Link(frame.Node, frame.Node, call, incoming));
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));
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, vertex.Children, GetCalls(call.Target)));
pending.Push(new Frame(node, 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>
/// <summary>The incoming call sets the target occurrence's exclusion radius.</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)),
}
);
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;
/// <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)
private static Point Anchor(GraphNode node, Point toward)
{
var labelWidth = Math.Clamp(FormatLabel(call.Target).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)
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)
@@ -229,30 +140,31 @@ public static class GraphLayout
};
}
public static double CalculateThickness(CallAnalysis call) =>
call.LinkKind switch
private static int KindWeight(LinkKind kind) =>
kind switch
{
LinkKind.Abstract => 1.5,
LinkKind.Direct => 2.5,
LinkKind.Event => 3.5,
_ => throw new ArgumentOutOfRangeException(nameof(call)),
LinkKind.Direct => 0,
LinkKind.Abstract => 1,
LinkKind.Event => 2,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
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 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, List<Vertex> children, IImmutableList<CallAnalysis> calls)
private sealed class Frame(GraphNode node, 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);
private sealed record Link(GraphNode Parent, GraphNode Child, CallAnalysis Call, bool Incoming);
}
ui/src/Graph/GraphScene.cs
+4
-8
diff --git a/ui/src/Graph/GraphScene.cs b/ui/src/Graph/GraphScene.cs
index b5e9614..0000bdf 100644
@@ -62,9 +62,11 @@ public sealed class GraphNode
public ISymbol Symbol { get; }
public string Label { get; }
public string? SourceLink { get; }
public string ToolTip => Label + Environment.NewLine + SourceLink;
public Point Location { get; internal set; }
public double Width { get; }
public double Height { get; }
public double FontSize => 13;
public bool IsOrigin { get; }
public bool IsCycleReference { get; }
public Rect Bounds => new(Location, new Size(Width, Height));
@@ -80,15 +82,9 @@ public sealed record GraphEdge(
)
{
/// <summary>Cubic control points shared by connection rendering and viewport fitting.</summary>
public Point Control1 =>
IsLoopback
? new Point(Start.X + 50, Start.Y)
: new Point(Start.X + Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), Start.Y);
public Point Control1 => IsLoopback ? new Point(Start.X + 50, Start.Y) : Start + (End - Start) / 3;
public Point Control2 =>
IsLoopback
? new Point(End.X + 50, End.Y)
: new Point(End.X - Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), End.Y);
public Point Control2 => IsLoopback ? new Point(End.X + 50, End.Y) : End - (End - Start) / 3;
public StreamGeometry CreateCurveGeometry()
{
ui/src/Graph/PhysicsLayout.cs
+284
-0
diff --git a/ui/src/Graph/PhysicsLayout.cs b/ui/src/Graph/PhysicsLayout.cs
new file mode 100644
index 0000000..fef0e6c
@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Avalonia;
namespace Cfa.Ui.Graph;
public sealed record PhysicsNode(int Parent, double Radius);
public sealed record PhysicsOptions
{
// Tick-based limits keep the same input reproducible across machines.
public int MaxIterations { get; init; } = 600;
public double SpringStrength { get; init; } = 0.025;
public double Damping { get; init; } = 0.7;
public double MaxSpeed { get; init; } = 20;
public double Tolerance { get; init; } = 0.05;
public double EdgeCrossingStrength { get; init; } = 0.12;
public double EdgeNodeStrength { get; init; } = 0.15;
public double EdgeClearance { get; init; } = 40;
}
/// <summary>Occurrence-tree physics. A node's descendants may enter its radius; all other nodes may not.</summary>
public sealed class PhysicsLayout
{
private readonly PhysicsNode[] nodes;
private readonly PhysicsOptions options;
private readonly Point[] positions;
private readonly Vector[] velocities;
private readonly int[] enter;
private readonly int[] exit;
private readonly double cellSize;
public IReadOnlyList<Point> Positions { get; }
public int Iterations { get; private set; }
public bool IsStable { get; private set; }
public double MaximumOverlap { get; private set; }
public PhysicsLayout(
IReadOnlyList<PhysicsNode> nodes,
PhysicsOptions? options = null,
IReadOnlyList<Point>? initialPositions = null
)
{
ArgumentNullException.ThrowIfNull(nodes);
this.options = options ?? new PhysicsOptions();
if (
this.options.MaxIterations < 1
|| !double.IsFinite(this.options.SpringStrength)
|| this.options.SpringStrength <= 0
|| !double.IsFinite(this.options.Damping)
|| this.options.Damping < 0
|| this.options.Damping >= 1
|| !double.IsFinite(this.options.MaxSpeed)
|| this.options.MaxSpeed <= 0
|| !double.IsFinite(this.options.Tolerance)
|| this.options.Tolerance <= 0
|| !double.IsFinite(this.options.EdgeCrossingStrength)
|| this.options.EdgeCrossingStrength < 0
|| !double.IsFinite(this.options.EdgeNodeStrength)
|| this.options.EdgeNodeStrength < 0
|| !double.IsFinite(this.options.EdgeClearance)
|| this.options.EdgeClearance <= 0
)
throw new ArgumentOutOfRangeException(nameof(options));
this.nodes = nodes.ToArray();
positions = new Point[nodes.Count];
velocities = new Vector[nodes.Count];
enter = new int[nodes.Count];
exit = new int[nodes.Count];
var children = Enumerable.Range(0, nodes.Count).Select(_ => new List<int>()).ToArray();
for (var i = 0; i < nodes.Count; i++)
{
var node = nodes[i];
if (
node.Parent < -1
|| node.Parent >= i
|| (i > 0 && node.Parent < 0)
|| !double.IsFinite(node.Radius)
|| node.Radius <= 0
)
throw new ArgumentException(
"Nodes must form one parent-before-child tree with finite positive radii.",
nameof(nodes)
);
if (i > 0)
children[node.Parent].Add(i);
}
var clock = 0;
var traversal = new Stack<(int Id, bool Leaving)>();
if (nodes.Count > 0)
traversal.Push((0, false));
while (traversal.TryPop(out var item))
{
if (item.Leaving)
exit[item.Id] = clock;
else
{
enter[item.Id] = clock++;
traversal.Push((item.Id, true));
for (var i = children[item.Id].Count - 1; i >= 0; i--)
traversal.Push((children[item.Id][i], false));
}
}
if (initialPositions is not null)
{
if (
initialPositions.Count != nodes.Count
|| initialPositions.Any(point => !double.IsFinite(point.X) || !double.IsFinite(point.Y))
)
throw new ArgumentException("Supply one finite position per node.", nameof(initialPositions));
for (var i = 0; i < nodes.Count; i++)
positions[i] = initialPositions[i] - initialPositions[0];
}
else
{
for (var i = 0; i < nodes.Count; i++)
{
var heading =
i == 0
? 0
: Math.Atan2(
positions[i].Y - positions[nodes[i].Parent].Y,
positions[i].X - positions[nodes[i].Parent].X
);
for (var sibling = 0; sibling < children[i].Count; sibling++)
{
var child = children[i][sibling];
var angle =
heading
+ (
i == 0
? sibling * Math.PI * 2 / children[i].Count
: (sibling - (children[i].Count - 1) / 2d) * Math.PI / Math.Max(1, children[i].Count)
);
positions[child] =
positions[i] + new Vector(Math.Cos(angle), Math.Sin(angle)) * MinimumDistance(i, child);
}
}
}
cellSize = nodes.Count > 0 ? nodes.Max(node => node.Radius) : 1;
Positions = Array.AsReadOnly(positions);
}
public double MinimumDistance(int first, int second)
{
ArgumentOutOfRangeException.ThrowIfNegative(first);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(first, nodes.Length);
ArgumentOutOfRangeException.ThrowIfNegative(second);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(second, nodes.Length);
if (first == second)
return 0;
var firstRadius = IsDescendant(first, second) ? 0 : nodes[first].Radius;
var secondRadius = IsDescendant(second, first) ? 0 : nodes[second].Radius;
return Math.Max(firstRadius, secondRadius);
}
private bool IsDescendant(int ancestor, int child) =>
enter[ancestor] < enter[child] && enter[child] < exit[ancestor];
public void Settle(CancellationToken cancellationToken = default)
{
var quietTicks = 0;
while (Iterations < options.MaxIterations && quietTicks < 8)
{
var movement = Tick(cancellationToken);
quietTicks = movement < options.Tolerance && MaximumOverlap < options.Tolerance ? quietTicks + 1 : 0;
}
IsStable = quietTicks >= 8;
}
public double Tick(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var before = (Point[])positions.Clone();
var forces = new Vector[nodes.Length];
for (var i = 1; i < nodes.Length; i++)
{
var parent = nodes[i].Parent;
var delta = positions[i] - positions[parent];
var distance = new Vector(delta.X, delta.Y).Length;
var direction = Direction(parent, i, distance);
var force = direction * ((distance - MinimumDistance(parent, i) * 1.05) * options.SpringStrength);
forces[i] -= force;
forces[parent] += force;
}
EdgeAvoidance.Apply(nodes, positions, forces, options, cancellationToken);
for (var i = 1; i < nodes.Length; i++)
{
velocities[i] = (velocities[i] + forces[i]) * options.Damping;
if (velocities[i].Length > options.MaxSpeed)
velocities[i] *= options.MaxSpeed / velocities[i].Length;
positions[i] += velocities[i];
}
// Project exclusion violations after integration. Descendants are exempt only
// from their ancestor's radius, not from their own radius facing that ancestor.
MaximumOverlap = 0;
for (var sweep = 0; sweep < 32; sweep++)
{
var maximumOverlap = 0d;
var cells = new Dictionary<(int X, int Y), List<int>>();
for (var i = 0; i < nodes.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var key = Cell(positions[i]);
for (var x = key.X - 1; x <= key.X + 1; x++)
for (var y = key.Y - 1; y <= key.Y + 1; y++)
{
if (!cells.TryGetValue((x, y), out var neighbors))
continue;
foreach (var j in neighbors)
{
var delta = positions[i] - positions[j];
var distance = new Vector(delta.X, delta.Y).Length;
var overlap = MinimumDistance(j, i) - distance;
if (overlap <= 0)
continue;
maximumOverlap = Math.Max(maximumOverlap, overlap);
var correction = Direction(j, i, distance) * overlap;
positions[i] += correction * (j == 0 ? 1 : 0.5);
if (j != 0)
positions[j] -= correction * 0.5;
}
}
var updated = Cell(positions[i]);
if (!cells.TryGetValue(updated, out var members))
cells[updated] = members = [];
members.Add(i);
}
MaximumOverlap = maximumOverlap;
if (maximumOverlap < options.Tolerance / 10)
break;
}
MaximumOverlap = MeasureOverlap(cancellationToken);
Iterations++;
IsStable = false;
var maximum = 0d;
for (var i = 1; i < nodes.Length; i++)
{
var delta = positions[i] - before[i];
maximum = Math.Max(maximum, new Vector(delta.X, delta.Y).Length);
}
return maximum;
}
private double MeasureOverlap(CancellationToken cancellationToken)
{
var maximum = 0d;
var cells = new Dictionary<(int X, int Y), List<int>>();
for (var i = 0; i < nodes.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var key = Cell(positions[i]);
for (var x = key.X - 1; x <= key.X + 1; x++)
for (var y = key.Y - 1; y <= key.Y + 1; y++)
{
if (!cells.TryGetValue((x, y), out var neighbors))
continue;
foreach (var j in neighbors)
{
var delta = positions[i] - positions[j];
maximum = Math.Max(maximum, MinimumDistance(j, i) - new Vector(delta.X, delta.Y).Length);
}
}
if (!cells.TryGetValue(key, out var members))
cells[key] = members = [];
members.Add(i);
}
return maximum;
}
private (int X, int Y) Cell(Point point) =>
((int)Math.Floor(point.X / cellSize), (int)Math.Floor(point.Y / cellSize));
private Vector Direction(int first, int second, double distance)
{
if (distance > 1e-9)
return new Vector(positions[second].X - positions[first].X, positions[second].Y - positions[first].Y)
/ distance;
var angle = (first + second + 1) * 2.399963229728653;
return new Vector(Math.Cos(angle), Math.Sin(angle));
}
}
ui/src/Views/MainWindow.axaml
+4
-2
diff --git a/ui/src/Views/MainWindow.axaml b/ui/src/Views/MainWindow.axaml
index 12c0945..98d8df6 100644
@@ -93,10 +93,11 @@
Classes.cycle="{Binding IsCycleReference}"
Width="{Binding Width}"
Height="{Binding Height}"
FontSize="{Binding FontSize}"
Content="{Binding Label}"
CommandParameter="{Binding}"
Command="{Binding $parent[Window].((vm:MainViewModel)DataContext).OpenSourceCommand}"
ToolTip.Tip="{Binding SourceLink}"
ToolTip.Tip="{Binding ToolTip}"
AutomationProperties.Name="{Binding Label}"
>
<Button.Styles>
@@ -122,8 +123,9 @@
<TextBlock
Text="{Binding}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="3"
TextAlignment="Center"
FontSize="13"
/>
</DataTemplate>
</Button.ContentTemplate>
ui/tests/GraphLayoutTests/BuildShould.cs
+30
-57
diff --git a/ui/tests/GraphLayoutTests/BuildShould.cs b/ui/tests/GraphLayoutTests/BuildShould.cs
index 7fbb193..10d70bd 100644
@@ -12,7 +12,7 @@ using Xunit;
namespace Cfa.Ui.Tests.GraphLayoutTests;
public sealed class BuildShould
public sealed partial class BuildShould
{
private static readonly INamedTypeSymbol Type = CreateType(
"Root",
@@ -26,11 +26,18 @@ public sealed class BuildShould
);
[Fact]
public void MirrorIncomingAndOutgoingTreesWithDirectedBoundaryAnchors()
public void PreserveCallDirectionWithoutRestrictingDistantClustersToSides()
{
// Arrange
var children = Map(("Root", ["Left", "Right"]), ("Left", ["Leaf"]), ("Right", ["Other", "Tail"]));
var analysis = Analysis(children, children);
var distant = children.ToImmutableDictionary(
entry => entry.Key,
entry =>
(IImmutableList<CallAnalysis>)
entry.Value.Select(call => call with { Reach = CallReach.CrossAssembly }).ToImmutableList(),
SymbolEqualityComparer.Default
);
var analysis = Analysis(distant, distant);
// Act
var scene = GraphLayout.Build(analysis);
@@ -42,16 +49,14 @@ public sealed class BuildShould
var group in scene.Nodes.Where(n => !n.IsOrigin).GroupBy(n => n.Symbol, SymbolEqualityComparer.Default)
)
{
var pair = group.OrderBy(n => n.Location.X).ToArray();
var pair = group.ToArray();
pair.Length.ShouldBe(2);
pair[0].Bounds.Center.X.ShouldBe(-pair[1].Bounds.Center.X, 0.000001);
pair[0].Bounds.Center.Y.ShouldBe(pair[1].Bounds.Center.Y, 0.000001);
pair[0].Location.ShouldNotBe(pair[1].Location);
}
foreach (var edge in scene.Edges)
{
edge.Start.ShouldBe(new Point(edge.Source.Bounds.Right, edge.Source.Bounds.Center.Y));
edge.End.ShouldBe(new Point(edge.Target.Bounds.Left, edge.Target.Bounds.Center.Y));
edge.Start.X.ShouldBeLessThan(edge.End.X);
edge.Source.Bounds.Inflate(0.000001).Contains(edge.Start).ShouldBeTrue();
edge.Target.Bounds.Inflate(0.000001).Contains(edge.End).ShouldBeTrue();
}
AssertNoOverlap(scene);
}
@@ -150,23 +155,16 @@ public sealed class BuildShould
}
[Fact]
public void UseReadableShrinkingNodesAndIncreasingLinkDistances()
public void IncreaseExclusionRadiusAndThicknessWithReachAndKind()
{
// Arrange
var call = new CallAnalysis(Symbol("Left"), LinkKind.Direct, CallReach.SameType);
var once = GraphLayout.CalculateNodeSize(call, 1);
// Act
var repeated = GraphLayout.CalculateNodeSize(call, 32);
var frequent = GraphLayout.CalculateNodeSize(call, uint.MaxValue);
var kinds = new[] { LinkKind.Abstract, LinkKind.Direct, LinkKind.Event };
var kinds = new[] { LinkKind.Direct, LinkKind.Abstract, LinkKind.Event };
var reaches = Enum.GetValues<CallReach>();
// Assert
repeated.Width.ShouldBeLessThan(once.Width);
repeated.Height.ShouldBeLessThan(once.Height);
frequent.Width.ShouldBeGreaterThan(100);
frequent.Height.ShouldBeGreaterThan(30);
foreach (var reach in reaches)
{
var lengths = kinds
@@ -185,42 +183,23 @@ public sealed class BuildShould
}
GraphLayout
.CalculateThickness(call with { LinkKind = LinkKind.Abstract })
.ShouldBeLessThan(GraphLayout.CalculateThickness(call));
.ShouldBeGreaterThan(GraphLayout.CalculateThickness(call));
GraphLayout
.CalculateThickness(call with { LinkKind = LinkKind.Event })
.ShouldBeGreaterThan(GraphLayout.CalculateThickness(call));
}
[Fact]
public void EmphasizeEventsAndCrossAssemblyCallsWhileShrinkingRepeatedOccurrences()
public void KeepNodeDimensionsAndFontsIndependentOfAppearanceCountsForNow()
{
// Arrange
var local = new CallAnalysis(Symbol("Helper"), LinkKind.Direct, CallReach.SameType);
var eventHandler = local with { LinkKind = LinkKind.Event };
var external = local with { Reach = CallReach.CrossAssembly };
var externalEvent = eventHandler with { Reach = CallReach.CrossAssembly };
var localSize = GraphLayout.CalculateNodeSize(local, 1);
var graph = Analysis(Map(("Root", ["Left", "Helper"])));
// Act
var eventSize = GraphLayout.CalculateNodeSize(eventHandler, 1);
var externalSize = GraphLayout.CalculateNodeSize(external, 1);
var prominentSize = GraphLayout.CalculateNodeSize(externalEvent, 1);
var repeatedSize = GraphLayout.CalculateNodeSize(externalEvent, 16);
var frequentSize = GraphLayout.CalculateNodeSize(externalEvent, uint.MaxValue);
var scene = GraphLayout.Build(graph with { Appearances = graph.Appearances.SetItem(Symbol("Helper"), 100) });
// Assert
eventSize.Width.ShouldBeGreaterThan(localSize.Width);
eventSize.Height.ShouldBeGreaterThan(localSize.Height);
externalSize.Width.ShouldBeGreaterThan(localSize.Width);
externalSize.Height.ShouldBeGreaterThan(localSize.Height);
prominentSize.Width.ShouldBeGreaterThan(eventSize.Width);
prominentSize.Height.ShouldBeGreaterThan(eventSize.Height);
prominentSize.Width.ShouldBeGreaterThan(externalSize.Width);
prominentSize.Height.ShouldBeGreaterThan(externalSize.Height);
repeatedSize.Width.ShouldBeLessThan(prominentSize.Width);
repeatedSize.Height.ShouldBeLessThan(prominentSize.Height);
frequentSize.Width.ShouldBeGreaterThan(100);
frequentSize.Height.ShouldBeGreaterThan(30);
scene.Nodes.ShouldAllBe(node => node.Width == 200 && node.Height == 72 && node.FontSize == 13);
}
[Fact]
@@ -241,8 +220,9 @@ public sealed class BuildShould
for (var i = 0; i < scenes.Length; i++)
{
var edge = scenes[i].Edges.Single();
(edge.End.X - edge.Start.X).ShouldBe(GraphLayout.CalculateEdgeLength(calls[i]), 0.000001);
edge.Start.Y.ShouldBe(edge.End.Y);
var delta = edge.Target.Bounds.Center - edge.Source.Bounds.Center;
var distance = new Vector(delta.X, delta.Y).Length;
distance.ShouldBe(GraphLayout.CalculateEdgeLength(calls[i]) * 1.05, 2);
}
}
@@ -287,7 +267,7 @@ public sealed class BuildShould
}
[Fact]
public void ClusterSmallHelpersOnASemicircleWithoutOverlap()
public void PlaceLocalHelpersOnBothSidesRatherThanInTreeColumns()
{
// Arrange
var analysis = Analysis(Map(("Root", ["Left", "Helper", "Right"])));
@@ -297,19 +277,12 @@ public sealed class BuildShould
// Assert
var edges = scene.Edges.ToArray();
edges[0].End.Y.ShouldBe(-edges[2].End.Y);
edges[0].End.X.ShouldBe(edges[2].End.X);
edges[1].End.Y.ShouldBe(0);
edges[1].End.X.ShouldBeGreaterThan(edges[0].End.X);
scene.Nodes.ShouldContain(node => node.Bounds.Center.Y > 0);
scene.Nodes.ShouldContain(node => node.Bounds.Center.Y < 0);
foreach (var edge in edges)
{
var distance = new Vector(edge.End.X - edge.Start.X, edge.End.Y - edge.Start.Y).Length;
distance.ShouldBe(
GraphLayout.CalculateEdgeLength(
new CallAnalysis(edge.Target.Symbol, LinkKind.Direct, CallReach.SameType)
),
0.000001
);
var delta = edge.Target.Bounds.Center - edge.Source.Bounds.Center;
new Vector(delta.X, delta.Y).Length.ShouldBeGreaterThanOrEqualTo(209.9);
}
AssertNoOverlap(scene);
}
@@ -477,7 +450,7 @@ public sealed class BuildShould
// Assert
scene.Nodes.Count.ShouldBe(depth);
scene.Edges.Count.ShouldBe(depth - 1);
scene.Nodes[^1].Location.X.ShouldBeGreaterThan(scene.Nodes[^2].Bounds.Right);
scene.Nodes[^1].Location.ShouldNotBe(scene.Nodes[^2].Location);
}
[Fact]
ui/tests/MainWindowTests/InteractShould.cs
+8
-2
diff --git a/ui/tests/MainWindowTests/InteractShould.cs b/ui/tests/MainWindowTests/InteractShould.cs
index 09bdac7..b7078e6 100644
@@ -242,8 +242,8 @@ public sealed class InteractShould
// Assert
var scene = model.Scene.ShouldNotBeNull();
scene.Nodes.Count.ShouldBe(5);
scene.Nodes.Single(node => node.Symbol.Name == "Request").Location.X.ShouldBeLessThan(0);
scene.Nodes.Single(node => node.Symbol.Name == "Handle").Location.X.ShouldBeGreaterThan(0);
scene.Nodes.Single(node => node.Symbol.Name == "Request").IsOrigin.ShouldBeFalse();
scene.Nodes.Single(node => node.Symbol.Name == "Handle").IsOrigin.ShouldBeFalse();
scene.Edges.ShouldContain(edge => edge.IsLoopback && edge.Source == edge.Target);
var buttons = window
.GetVisualDescendants()
@@ -255,6 +255,12 @@ public sealed class InteractShould
Equals(button.Content, "Flow." + ((GraphNode)button.DataContext!).Symbol.Name + "()")
);
buttons.ShouldAllBe(button => button.Bounds.Width > 0 && button.Bounds.Height > 0);
foreach (var button in buttons)
{
var node = (GraphNode)button.DataContext!;
button.FontSize.ShouldBe(node.FontSize);
button.GetVisualDescendants().OfType<TextBlock>().ShouldAllBe(text => text.FontSize == node.FontSize);
}
var editor = window.FindControl<NodifyEditor>("Editor")!;
var bounds = scene.Nodes.Select(node => node.Bounds).Aggregate((first, next) => first.Union(next));
var center = (bounds.Center - editor.ViewportLocation) * editor.ViewportZoom;
ui/tests/PhysicsLayoutTests/AvoidCrossingsShould.cs
+119
-0
diff --git a/ui/tests/PhysicsLayoutTests/AvoidCrossingsShould.cs b/ui/tests/PhysicsLayoutTests/AvoidCrossingsShould.cs
new file mode 100644
index 0000000..ec73487
@@ -0,0 +1,119 @@
using System;
using System.Linq;
using Avalonia;
using Cfa.Ui.Graph;
using Shouldly;
using Xunit;
namespace Cfa.Ui.Tests.PhysicsLayoutTests;
public sealed class AvoidCrossingsShould
{
[Fact]
public void UntangleCrossedBranchesDeterministically()
{
// Arrange
PhysicsNode[] nodes = [new(-1, 10), new(0, 10), new(0, 10), new(1, 10), new(2, 10)];
Point[] starts = [new(0, -200), new(-100, 0), new(100, 0), new(100, 200), new(-100, 200)];
var options = new PhysicsOptions { SpringStrength = 0.001, EdgeNodeStrength = 0 };
var layout = new PhysicsLayout(nodes, options, starts);
var repeat = new PhysicsLayout(nodes, options, starts);
// Act
layout.Settle();
repeat.Settle();
// Assert
Crosses(layout.Positions[1], layout.Positions[3], layout.Positions[2], layout.Positions[4]).ShouldBeFalse();
layout.Positions.ShouldBe(repeat.Positions);
layout.Positions.ShouldAllBe(point => double.IsFinite(point.X) && double.IsFinite(point.Y));
}
[Fact]
public void PushAnUnrelatedNodeOffAnEdgeEvenWhenExactlyAligned()
{
// Arrange
PhysicsNode[] nodes = [new(-1, 10), new(0, 10), new(0, 10)];
Point[] starts = [default, new(400, 0), new(200, 0)];
var layout = new PhysicsLayout(nodes, new PhysicsOptions { EdgeCrossingStrength = 0 }, starts);
// Act
layout.Tick();
// Assert
Math.Abs(layout.Positions[2].Y).ShouldBeGreaterThan(0);
}
[Fact]
public void LeaveSharedEndpointsAlone()
{
// Arrange
PhysicsNode[] nodes = [new(-1, 10), new(0, 10), new(0, 10)];
Point[] starts = [default, new(200, 0), new(0, 200)];
var enabled = new PhysicsLayout(nodes, new PhysicsOptions { EdgeNodeStrength = 0 }, starts);
var disabled = new PhysicsLayout(
nodes,
new PhysicsOptions { EdgeCrossingStrength = 0, EdgeNodeStrength = 0 },
starts
);
// Act
enabled.Tick();
disabled.Tick();
// Assert
enabled.Positions.ShouldBe(disabled.Positions);
}
[Theory]
[InlineData(20, true)]
[InlineData(60, false)]
public void ApplyNodeAvoidanceOnlyWithinTheClearance(double distance, bool shouldDiffer)
{
// Arrange
PhysicsNode[] nodes = [new(-1, 10), new(0, 10), new(0, 10)];
Point[] starts = [default, new(400, 0), new(200, distance)];
var enabled = new PhysicsLayout(nodes, new PhysicsOptions { EdgeCrossingStrength = 0 }, starts);
var disabled = new PhysicsLayout(
nodes,
new PhysicsOptions { EdgeCrossingStrength = 0, EdgeNodeStrength = 0 },
starts
);
// Act
enabled.Tick();
disabled.Tick();
// Assert
enabled.Positions.SequenceEqual(disabled.Positions).ShouldBe(!shouldDiffer);
}
[Theory]
[InlineData(20, true)]
[InlineData(80, false)]
public void RepelNearlyCrossingIndependentEdgesWithoutAnIntersection(double distance, bool shouldDiffer)
{
// Arrange
PhysicsNode[] nodes = [new(-1, 1), new(0, 1), new(0, 1), new(1, 1), new(2, 1)];
Point[] starts = [new(-300, 200), new(0, 0), new(100, distance), new(200, 0), new(100, 200)];
var enabled = new PhysicsLayout(nodes, new PhysicsOptions { EdgeNodeStrength = 0 }, starts);
var disabled = new PhysicsLayout(
nodes,
new PhysicsOptions { EdgeNodeStrength = 0, EdgeCrossingStrength = 0 },
starts
);
// Act
enabled.Tick();
disabled.Tick();
// Assert
enabled.Positions.SequenceEqual(disabled.Positions).ShouldBe(!shouldDiffer);
}
private static bool Crosses(Point a, Point b, Point c, Point d)
{
static double Side(Point p, Point q, Point r) => (q.X - p.X) * (r.Y - p.Y) - (q.Y - p.Y) * (r.X - p.X);
return Side(a, b, c) * Side(a, b, d) < 0 && Side(c, d, a) * Side(c, d, b) < 0;
}
}
ui/tests/PhysicsLayoutTests/ConstructShould.cs
+106
-0
diff --git a/ui/tests/PhysicsLayoutTests/ConstructShould.cs b/ui/tests/PhysicsLayoutTests/ConstructShould.cs
new file mode 100644
index 0000000..711da7a
@@ -0,0 +1,106 @@
using System;
using Avalonia;
using Cfa.Ui.Graph;
using Shouldly;
using Xunit;
namespace Cfa.Ui.Tests.PhysicsLayoutTests;
public sealed class ConstructShould
{
[Fact]
public void RejectMissingOrNonfiniteInitialCoordinates()
{
// Arrange
PhysicsNode[] nodes = [new(-1, 100), new(0, 100)];
Point[][] starts =
[
[default],
[default, new Point(double.NaN, 0)],
];
foreach (var start in starts)
{
// Act
var construct = () => new PhysicsLayout(nodes, initialPositions: start);
// Assert
construct.ShouldThrow<ArgumentException>();
}
}
[Fact]
public void NormalizeTheSuppliedOriginToZeroWithoutChangingRelativePositions()
{
// Arrange
Point[] starts = [new(50, 70), new(150, 100)];
// Act
var layout = new PhysicsLayout([new(-1, 100), new(0, 100)], initialPositions: starts);
// Assert
layout.Positions[0].ShouldBe(default(Point));
layout.Positions[1].ShouldBe(new Point(100, 30));
starts[0].ShouldBe(new Point(50, 70));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void RejectInvalidRadii(double radius)
{
// Arrange
PhysicsNode[] nodes = [new(-1, radius)];
// Act
var construct = () => new PhysicsLayout(nodes);
// Assert
construct.ShouldThrow<ArgumentException>();
}
[Theory]
[InlineData(-1)]
[InlineData(1)]
[InlineData(5)]
public void RejectMissingParentsAndCycles(int parent)
{
// Arrange
PhysicsNode[] nodes = [new(-1, 100), new(parent, 100)];
// Act
var construct = () => new PhysicsLayout(nodes);
// Assert
construct.ShouldThrow<ArgumentException>();
}
[Fact]
public void RejectInvalidSimulationSettings()
{
// Arrange
PhysicsOptions[] settings =
[
new() { MaxIterations = 0 },
new() { Damping = 1 },
new() { Damping = -1 },
new() { MaxSpeed = 0 },
new() { SpringStrength = double.NaN },
new() { Tolerance = 0 },
new() { EdgeCrossingStrength = -1 },
new() { EdgeNodeStrength = double.NaN },
new() { EdgeClearance = 0 },
];
foreach (var setting in settings)
{
// Act
var construct = () => new PhysicsLayout([], setting);
// Assert
construct.ShouldThrow<ArgumentOutOfRangeException>();
}
}
}
ui/tests/PhysicsLayoutTests/SettleShould.cs
+254
-0
diff --git a/ui/tests/PhysicsLayoutTests/SettleShould.cs b/ui/tests/PhysicsLayoutTests/SettleShould.cs
new file mode 100644
index 0000000..7dbac18
@@ -0,0 +1,254 @@
using System;
using System.Linq;
using System.Threading;
using Avalonia;
using Cfa.Ui.Graph;
using Shouldly;
using Xunit;
namespace Cfa.Ui.Tests.PhysicsLayoutTests;
public sealed class SettleShould
{
[Fact]
public void KeepTheOriginPinnedWhilePullingADistantChildCloser()
{
// Arrange
var layout = new PhysicsLayout([new(-1, 100), new(0, 100)], initialPositions: [default, new Point(1000, 0)]);
// Act
layout.Settle();
// Assert
layout.Positions[0].ShouldBe(default(Point));
Distance(layout.Positions[0], layout.Positions[1]).ShouldBe(105, 2);
layout.IsStable.ShouldBeTrue();
layout.MaximumOverlap.ShouldBeLessThan(0.05);
layout.Iterations.ShouldBeLessThan(600);
}
[Fact]
public void LetDescendantsEnterAnAncestorsZoneButKeepUnrelatedNodesOutside()
{
// Arrange
var layout = new PhysicsLayout([new(-1, 100), new(0, 600), new(1, 50), new(0, 100)]);
// Act
layout.Settle();
// Assert
layout.MinimumDistance(1, 2).ShouldBe(50);
layout.MinimumDistance(1, 3).ShouldBe(600);
Distance(layout.Positions[1], layout.Positions[2]).ShouldBeLessThan(150);
Distance(layout.Positions[1], layout.Positions[3]).ShouldBeGreaterThanOrEqualTo(599.9);
AssertExclusions(layout);
}
[Fact]
public void ExemptGrandchildrenAndDeeperDescendantsNotJustImmediateChildren()
{
// Arrange
var layout = new PhysicsLayout([new(-1, 1000), new(0, 100), new(1, 70), new(2, 30), new(0, 60)]);
// Act
var deepDistance = layout.MinimumDistance(0, 3);
var unrelatedDistance = layout.MinimumDistance(3, 4);
// Assert
deepDistance.ShouldBe(30);
unrelatedDistance.ShouldBe(60);
layout.MinimumDistance(3, 0).ShouldBe(deepDistance);
layout.MinimumDistance(2, 2).ShouldBe(0);
}
[Theory]
[InlineData(4)]
[InlineData(12)]
[InlineData(32)]
public void SeparateSiblingsAroundTheirParent(int count)
{
// Arrange
var nodes = Enumerable.Range(0, count + 1).Select(i => new PhysicsNode(i == 0 ? -1 : 0, 100)).ToArray();
var layout = new PhysicsLayout(nodes);
// Act
layout.Settle();
// Assert
AssertExclusions(layout);
layout.Positions.ShouldContain(point => point.X < -10);
layout.Positions.ShouldContain(point => point.X > 10);
layout.Positions.ShouldContain(point => point.Y < -10);
layout.Positions.ShouldContain(point => point.Y > 10);
}
[Fact]
public void KeepAFarBranchesOwnHelpersCloseWhileRepellingItsSiblings()
{
// Arrange
var layout = new PhysicsLayout([
new(-1, 100),
new(0, 700),
new(1, 80),
new(1, 80),
new(0, 100),
new(4, 80),
new(4, 80),
]);
// Act
layout.Settle();
// Assert
Distance(layout.Positions[1], layout.Positions[2]).ShouldBeLessThan(250);
Distance(layout.Positions[1], layout.Positions[3]).ShouldBeLessThan(250);
foreach (var outside in new[] { 0, 4, 5, 6 })
Distance(layout.Positions[1], layout.Positions[outside]).ShouldBeGreaterThanOrEqualTo(699.9);
AssertExclusions(layout);
}
[Fact]
public void ResolveCoincidentNodesWithoutRandomnessOrNonfiniteCoordinates()
{
// Arrange
PhysicsNode[] nodes = [new(-1, 100), new(0, 100), new(0, 100), new(1, 60)];
var layout = new PhysicsLayout(nodes, initialPositions: new Point[nodes.Length]);
var second = new PhysicsLayout(nodes, initialPositions: new Point[nodes.Length]);
// Act
layout.Settle();
second.Settle();
// Assert
layout.Positions.ShouldBe(second.Positions);
layout.Positions.ShouldAllBe(point => double.IsFinite(point.X) && double.IsFinite(point.Y));
AssertExclusions(layout);
}
[Fact]
public void StopAtTheIterationLimitEvenWithoutConvergence()
{
// Arrange
var layout = new PhysicsLayout(
[new(-1, 100), new(0, 100)],
new PhysicsOptions { MaxIterations = 2 },
[default, new Point(1000, 0)]
);
// Act
layout.Settle();
// Assert
layout.Iterations.ShouldBe(2);
layout.IsStable.ShouldBeFalse();
}
[Fact]
public void HonorCancellationBeforeMovingAnything()
{
// Arrange
var layout = new PhysicsLayout([new(-1, 100), new(0, 100)]);
var before = layout.Positions.ToArray();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
// Act
var settle = () => layout.Settle(cancellation.Token);
// Assert
settle.ShouldThrow<OperationCanceledException>();
layout.Positions.ShouldBe(before);
layout.Iterations.ShouldBe(0);
}
[Fact]
public void HandleEmptyAndSingleNodeSimulations()
{
// Arrange
var empty = new PhysicsLayout([]);
var single = new PhysicsLayout([new(-1, 100)]);
// Act
empty.Settle();
single.Settle();
// Assert
empty.Positions.ShouldBeEmpty();
single.Positions.ShouldHaveSingleItem().ShouldBe(default(Point));
empty.IsStable.ShouldBeTrue();
single.IsStable.ShouldBeTrue();
}
[Fact]
public void SupportParentBeforeChildInputEvenWhenSubtreesAreInterleaved()
{
// Arrange
var layout = new PhysicsLayout([new(-1, 100), new(0, 400), new(0, 200), new(1, 50), new(2, 60)]);
// Act
layout.Settle();
// Assert
layout.MinimumDistance(1, 3).ShouldBe(50);
layout.MinimumDistance(1, 4).ShouldBe(400);
AssertExclusions(layout);
}
[Fact]
public void KeepBothFarBranchesOutsideEachOthersZonesWhileTheirHelpersRemainInside()
{
// Arrange
var layout = new PhysicsLayout([
new(-1, 100),
new(0, 500),
new(1, 80),
new(1, 80),
new(0, 500),
new(4, 80),
new(4, 80),
]);
// Act
layout.Settle();
// Assert
AssertExclusions(layout);
foreach (var pair in new[] { (1, 2), (1, 3), (4, 5), (4, 6) })
Distance(layout.Positions[pair.Item1], layout.Positions[pair.Item2]).ShouldBeLessThan(200);
Distance(layout.Positions[1], layout.Positions[4]).ShouldBeGreaterThanOrEqualTo(499.9);
}
[Fact]
public void SettleAMixedHierarchyWithNoDirectionalRestrictions()
{
// Arrange
var random = new Random(7);
var nodes = Enumerable
.Range(0, 40)
.Select(i => new PhysicsNode(i == 0 ? -1 : random.Next(i), i % 5 == 0 ? 400 : 100))
.ToArray();
var layout = new PhysicsLayout(nodes);
// Act
layout.Settle();
// Assert
AssertExclusions(layout);
layout.Positions.ShouldAllBe(point => double.IsFinite(point.X) && double.IsFinite(point.Y));
layout.Iterations.ShouldBeLessThanOrEqualTo(600);
}
private static double Distance(Point first, Point second) =>
new Vector(first.X - second.X, first.Y - second.Y).Length;
private static void AssertExclusions(PhysicsLayout layout)
{
for (var i = 0; i < layout.Positions.Count; i++)
for (var j = i + 1; j < layout.Positions.Count; j++)
Distance(layout.Positions[i], layout.Positions[j])
.ShouldBeGreaterThanOrEqualTo(
layout.MinimumDistance(i, j) - 0.1,
$"Nodes {i} and {j} violate their exclusion radii."
);
}
}
ui/tests/PhysicsLayoutTests/TickShould.cs
+43
-0
diff --git a/ui/tests/PhysicsLayoutTests/TickShould.cs b/ui/tests/PhysicsLayoutTests/TickShould.cs
new file mode 100644
index 0000000..8d670a1
@@ -0,0 +1,43 @@
using System;
using Avalonia;
using Cfa.Ui.Graph;
using Shouldly;
using Xunit;
namespace Cfa.Ui.Tests.PhysicsLayoutTests;
public sealed class TickShould
{
[Fact]
public void ProjectAnOverlapOutsideTheRequiredRadiusInOneTick()
{
// Arrange
var layout = new PhysicsLayout([new(-1, 100), new(0, 300)], initialPositions: [default, new Point(10, 0)]);
// Act
layout.Tick();
// Assert
layout.Positions[1].X.ShouldBe(300, 0.001);
layout.Positions[0].ShouldBe(default(Point));
}
[Theory]
[InlineData(10)]
[InlineData(40)]
public void BoundIntegrationSpeedWithoutConstrainingExclusionCorrections(double maximum)
{
// Arrange
var layout = new PhysicsLayout(
[new(-1, 100), new(0, 100)],
new PhysicsOptions { MaxSpeed = maximum },
[default, new Point(10000, 0)]
);
// Act
layout.Tick();
// Assert
layout.Positions[1].X.ShouldBe(10000 - maximum, 0.00001);
}
}
ui/tests/Snapshots/analyzer-fit.png
+0
-0
diff --git a/ui/tests/Snapshots/analyzer-fit.png b/ui/tests/Snapshots/analyzer-fit.png
index 3a732ea..913ca98 100644
Binary files a/ui/tests/Snapshots/analyzer-fit.png and b/ui/tests/Snapshots/analyzer-fit.png differ