ui/src/Graph/GraphLayout.cs
+4
-3
diff --git a/ui/src/Graph/GraphLayout.cs b/ui/src/Graph/GraphLayout.cs
index 29f38c4..dd2c935 100644
@@ -52,13 +52,14 @@ public static class GraphLayout
var source = link.Direction < 0 ? link.Child : link.Parent;
var target = link.Direction < 0 ? link.Parent : link.Child;
var loopback = ReferenceEquals(source, target);
var thickness = CalculateThickness(link.Call);
var start = loopback
? new Point(source.Bounds.Right, source.Bounds.Center.Y - source.Height / 4)
? 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
? new Point(target.Bounds.Right, target.Bounds.Center.Y + target.Height / 4)
? 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, CalculateThickness(link.Call), loopback));
edges.Add(new GraphEdge(source, target, start, end, thickness, loopback));
}
return new GraphScene(nodes.AsReadOnly(), edges.AsReadOnly());
ui/src/Graph/GraphScene.cs
+30
-2
diff --git a/ui/src/Graph/GraphScene.cs b/ui/src/Graph/GraphScene.cs
index a86814a..b5e9614 100644
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Media;
using Microsoft.CodeAnalysis;
namespace Cfa.Ui.Graph;
@@ -81,11 +82,38 @@ public sealed record GraphEdge(
/// <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 + 50, Start.Y)
: 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 + 50, End.Y)
: new Point(End.X - Math.Max(40, Math.Abs(End.X - Start.X) * 0.45), End.Y);
public StreamGeometry CreateCurveGeometry()
{
var geometry = new StreamGeometry();
using var drawing = geometry.Open();
drawing.BeginFigure(Start, false);
drawing.CubicBezierTo(Control1, Control2, End);
drawing.EndFigure(false);
return geometry;
}
public StreamGeometry CreateArrowGeometry()
{
var geometry = new StreamGeometry();
var direction = new Vector(End.X - Control2.X, End.Y - Control2.Y);
if (direction.Length == 0)
return geometry;
direction /= direction.Length;
var normal = new Vector(-direction.Y, direction.X);
using var drawing = geometry.Open();
drawing.BeginFigure(End, true);
drawing.LineTo(End - direction * 13 + normal * 6);
drawing.LineTo(End - direction * 13 - normal * 6);
drawing.EndFigure(true);
return geometry;
}
}
ui/src/Views/GraphConnection.cs
+2
-27
diff --git a/ui/src/Views/GraphConnection.cs b/ui/src/Views/GraphConnection.cs
index 6b2ecfc..f5e7658 100644
@@ -27,33 +27,8 @@ public sealed class GraphConnection : Control
{
return;
}
var start = edge.Start;
var end = edge.End;
var control1 = edge.Control1;
var control2 = edge.Control2;
var geometry = new StreamGeometry();
using (var drawing = geometry.Open())
{
drawing.BeginFigure(start, false);
drawing.CubicBezierTo(control1, control2, end);
drawing.EndFigure(false);
}
var brush = Brushes.SkyBlue;
context.DrawGeometry(null, new Pen(brush, edge.Thickness), geometry);
var direction = new Vector(end.X - control2.X, end.Y - control2.Y);
if (direction.Length > 0)
{
direction /= direction.Length;
var normal = new Vector(-direction.Y, direction.X);
var arrow = new StreamGeometry();
using (var drawing = arrow.Open())
{
drawing.BeginFigure(end, true);
drawing.LineTo(end - direction * 13 + normal * 6);
drawing.LineTo(end - direction * 13 - normal * 6);
drawing.EndFigure(true);
}
context.DrawGeometry(brush, null, arrow);
}
context.DrawGeometry(null, new Pen(brush, edge.Thickness), edge.CreateCurveGeometry());
context.DrawGeometry(brush, null, edge.CreateArrowGeometry());
}
}
ui/tests/GraphEdgeTests/CreateCurveGeometryShould.cs
+64
-0
diff --git a/ui/tests/GraphEdgeTests/CreateCurveGeometryShould.cs b/ui/tests/GraphEdgeTests/CreateCurveGeometryShould.cs
new file mode 100644
index 0000000..1d1e3a6
@@ -0,0 +1,64 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using Avalonia;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Cfa.Ui.Graph;
using Microsoft.CodeAnalysis;
using Shouldly;
namespace Cfa.Ui.Tests.GraphEdgeTests;
public sealed class CreateCurveGeometryShould
{
[AvaloniaFact]
public void KeepTheLoopStrokeAndArrowOutsideSmallAndLargeNodes()
{
// Arrange
var original = TestGraph.Create();
foreach (var kind in Enum.GetValues<LinkKind>())
foreach (var appearances in new[] { 1u, uint.MaxValue })
{
var analysis = original with
{
CallsTo = original.CallsTo.ToImmutableDictionary(
entry => entry.Key,
entry =>
(IImmutableList<CallAnalysis>)
entry.Value.Select(call => call with { LinkKind = kind }).ToImmutableList(),
SymbolEqualityComparer.Default
),
Appearances = original.Appearances.ToImmutableDictionary(
entry => entry.Key,
_ => appearances,
SymbolEqualityComparer.Default
),
};
// Act
var scene = GraphLayout.Build(analysis);
var loop = scene.Edges.Single(edge => edge.IsLoopback);
var curve = loop.CreateCurveGeometry();
var arrow = loop.CreateArrowGeometry();
// Assert
var node = loop.Source.Bounds;
// The curve lies inside its control-point hull; the stroke adds half its width.
new[] { loop.Start, loop.Control1, loop.Control2, loop.End }.ShouldAllBe(point =>
point.X - loop.Thickness / 2 >= node.Right
);
arrow.Bounds.Left.ShouldBeGreaterThanOrEqualTo(node.Right);
curve.Bounds.Width.ShouldBeGreaterThan(0);
arrow.Bounds.Width.ShouldBeGreaterThan(0);
scene.Bounds.Contains(curve.GetRenderBounds(new Pen(Brushes.SkyBlue, loop.Thickness))).ShouldBeTrue();
scene.Bounds.Contains(arrow.Bounds).ShouldBeTrue();
for (var y = node.Top + 0.1; y < node.Bottom; y += 0.5)
{
var inside = new Point(node.Right - 0.1, y);
curve.StrokeContains(new Pen(Brushes.SkyBlue, loop.Thickness), inside).ShouldBeFalse();
arrow.FillContains(inside).ShouldBeFalse();
}
}
}
}
ui/tests/GraphLayoutTests/BuildShould.cs
+4
-3
diff --git a/ui/tests/GraphLayoutTests/BuildShould.cs b/ui/tests/GraphLayoutTests/BuildShould.cs
index ab5c196..e6808c1 100644
@@ -94,8 +94,10 @@ public sealed class BuildShould
{
loop.Source.ShouldBeSameAs(loop.Target);
loop.Start.ShouldNotBe(loop.End);
loop.Source.Bounds.Contains(loop.Start).ShouldBeTrue();
loop.Source.Bounds.Contains(loop.End).ShouldBeTrue();
loop.Start.X.ShouldBe(loop.Source.Bounds.Right + loop.Thickness / 2);
loop.End.X.ShouldBe(loop.Source.Bounds.Right + loop.Thickness / 2);
loop.Start.Y.ShouldBeGreaterThan(loop.Source.Bounds.Top);
loop.End.Y.ShouldBeLessThan(loop.Source.Bounds.Bottom);
}
scene.Nodes.ShouldAllBe(n => !n.IsCycleReference);
}
@@ -255,7 +257,6 @@ public sealed class BuildShould
// Assert
var loop = scene.Edges.Single(e => e.IsLoopback);
scene.Bounds.Right.ShouldBeGreaterThan(scene.Nodes.Max(n => n.Bounds.Right));
scene.Bounds.Top.ShouldBeLessThan(scene.Nodes.Min(n => n.Bounds.Top));
foreach (var node in scene.Nodes)
scene.Bounds.Contains(node.Bounds).ShouldBeTrue();
foreach (var edge in scene.Edges)