Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Feature: startup-io-rendering-optimisation, Task 8.2: PassPolarPlotControl render cache unit tests

using Avalonia.Media;
using FsCheck;
using FsCheck.Xunit;
using OscarWatch.Controls;

namespace OscarWatch.Tests.Performance;

/// <summary>
/// **Validates: Requirements 8.1, 8.3**
///
/// Verifies that <see cref="RenderResourceCache.GetRoundCapPen"/> returns the same object
/// reference on repeated calls, and that the returned pen has the expected LineCap/LineJoin properties.
/// </summary>
public class PassPolarPlotRenderCacheTests
{
/// <summary>
/// GetRoundCapPen returns the same reference for the same colour and thickness on repeated calls.
/// </summary>
[Property(MaxTest = 100)]
public bool GetRoundCapPen_returns_same_reference(byte a, byte r, byte g, byte b, PositiveInt thicknessRaw)
{
var color = Color.FromArgb(a, r, g, b);
var thickness = (double)thicknessRaw.Get / 10.0;
var cache = new RenderResourceCache();

var pen1 = cache.GetRoundCapPen(color, thickness);
var pen2 = cache.GetRoundCapPen(color, thickness);

return ReferenceEquals(pen1, pen2);
}

/// <summary>
/// GetRoundCapPen returns a pen with LineCap = Round and LineJoin = Round.
/// </summary>
[Property(MaxTest = 100)]
public bool GetRoundCapPen_has_round_cap_and_join(byte a, byte r, byte g, byte b, PositiveInt thicknessRaw)
{
var color = Color.FromArgb(a, r, g, b);
var thickness = (double)thicknessRaw.Get / 10.0;
var cache = new RenderResourceCache();

var pen = cache.GetRoundCapPen(color, thickness);

return pen.LineCap == PenLineCap.Round && pen.LineJoin == PenLineJoin.Round;
}

/// <summary>
/// GetRoundCapPen returns a pen with the correct thickness and brush colour.
/// </summary>
[Property(MaxTest = 100)]
public bool GetRoundCapPen_has_correct_thickness_and_color(byte a, byte r, byte g, byte b, PositiveInt thicknessRaw)
{
var color = Color.FromArgb(a, r, g, b);
var thickness = (double)thicknessRaw.Get / 10.0;
var cache = new RenderResourceCache();

var pen = cache.GetRoundCapPen(color, thickness);
var brush = pen.Brush as SolidColorBrush;

if (brush is null)
return false;

return pen.Thickness == thickness && brush.Color == color;
}

/// <summary>
/// Clear() removes round-cap pen entries so the next call creates a fresh pen.
/// </summary>
[Fact]
public void Clear_removes_round_cap_pens()
{
var cache = new RenderResourceCache();
var color = Colors.Red;

var pen1 = cache.GetRoundCapPen(color, 2.5);
cache.Clear();
var pen2 = cache.GetRoundCapPen(color, 2.5);

Assert.NotSame(pen1, pen2);
}

/// <summary>
/// Different colours produce different pen references (not shared incorrectly).
/// </summary>
[Fact]
public void GetRoundCapPen_different_colors_different_pens()
{
var cache = new RenderResourceCache();

var pen1 = cache.GetRoundCapPen(Colors.Red, 2.5);
var pen2 = cache.GetRoundCapPen(Colors.Blue, 2.5);

Assert.NotSame(pen1, pen2);
}
}
17 changes: 13 additions & 4 deletions OscarWatch/Controls/FormattedTextCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace OscarWatch.Controls;
/// </summary>
internal sealed class FormattedTextCache
{
private readonly Dictionary<(string Name, double FontSize), FormattedText> _cache = new();
private readonly Dictionary<(string Name, double FontSize, Color Foreground), FormattedText> _cache = new();
private SolidColorBrush? _labelBrush;
private SolidColorBrush? _backgroundBrush;
private Typeface? _typeface;
Expand All @@ -24,7 +24,16 @@ internal sealed class FormattedTextCache
/// </summary>
public FormattedText Get(string name, double fontSize, UiPalette palette)
{
var key = (name, fontSize);
return Get(name, fontSize, palette.MapLabelForeground);
}

/// <summary>
/// Returns a cached FormattedText for the given name, font size, and explicit foreground colour.
/// Creates the text on first request; returns the cached instance on subsequent calls with the same key.
/// </summary>
public FormattedText Get(string name, double fontSize, Color foreground)
{
var key = (name, fontSize, foreground);
if (!_cache.TryGetValue(key, out var text))
{
text = new FormattedText(
Expand All @@ -33,7 +42,7 @@ public FormattedText Get(string name, double fontSize, UiPalette palette)
FlowDirection.LeftToRight,
GetTypeface(),
fontSize,
GetLabelBrush(palette))
new SolidColorBrush(foreground))
{
MaxTextWidth = 120
};
Expand Down Expand Up @@ -94,7 +103,7 @@ public void Evict(IReadOnlyList<SatelliteTrackState> visibleStates)
if (_cache.Count == 0)
return;

var keysToRemove = new List<(string Name, double FontSize)>();
var keysToRemove = new List<(string Name, double FontSize, Color Foreground)>();

foreach (var key in _cache.Keys)
{
Expand Down
65 changes: 38 additions & 27 deletions OscarWatch/Controls/PassPolarPlotControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class PassPolarPlotControl : ThemeAwareControl

private PassPolarPlotHitTest.HoverPoint? _hoverPoint;
private readonly RenderResourceCache _renderCache = new();
private readonly FormattedTextCache _textCache = new();

public static readonly StyledProperty<PassPolarPlotData?> PlotDataProperty =
AvaloniaProperty.Register<PassPolarPlotControl, PassPolarPlotData?>(nameof(PlotData));
Expand All @@ -47,6 +48,26 @@ public PassPolarPlotControl()
PointerExited += OnPointerExited;
}

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (Application.Current is not null)
Application.Current.ActualThemeVariantChanged += OnThemeChangedClearCache;
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (Application.Current is not null)
Application.Current.ActualThemeVariantChanged -= OnThemeChangedClearCache;
base.OnDetachedFromVisualTree(e);
}

private void OnThemeChangedClearCache(object? sender, EventArgs e)
{
_renderCache.Clear();
_textCache.Clear();
}

public PassPolarPlotData? PlotData
{
get => GetValue(PlotDataProperty);
Expand Down Expand Up @@ -82,7 +103,7 @@ public override void Render(DrawingContext context)
var local = new Rect(0, 0, w, h);
var (cx, cy, plotRadius) = GetPlotGeometry(w, h);

context.FillRectangle(new SolidColorBrush(palette.SkyPlotBackground), local);
context.FillRectangle(_renderCache.GetBrush(palette.SkyPlotBackground), local);
DrawHorizonDisk(context, cx, cy, plotRadius, palette);
DrawElevationRing(context, cx, cy, plotRadius, 30, palette.SkyPlotRing30, 1);
DrawElevationRing(context, cx, cy, plotRadius, 60, palette.SkyPlotRing60, 1);
Expand All @@ -103,11 +124,7 @@ public override void Render(DrawingContext context)
continue;

var color = segment.IsSunlit ? palette.SunlightTimeline : EclipsePathColor;
var pen = new Pen(new SolidColorBrush(color), 2.5)
{
LineCap = PenLineCap.Round,
LineJoin = PenLineJoin.Round
};
var pen = _renderCache.GetRoundCapPen(color, 2.5);

var first = segment.Points[0];
if (!SkyPlotControl.TryAzElToPoint(cx, cy, plotRadius, first.AzimuthDeg, first.ElevationDeg, out var prev))
Expand Down Expand Up @@ -162,16 +179,16 @@ private static (double Cx, double Cy, double PlotRadius) GetPlotGeometry(double
return (cx, cy, plotRadius);
}

private static void DrawHorizonDisk(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
private void DrawHorizonDisk(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
{
var disk = new Rect(cx - plotRadius, cy - plotRadius, plotRadius * 2, plotRadius * 2);
context.DrawEllipse(
new SolidColorBrush(palette.SkyPlotBackground),
new Pen(new SolidColorBrush(palette.SkyPlotBorder), 1.5),
_renderCache.GetBrush(palette.SkyPlotBackground),
_renderCache.GetPen(palette.SkyPlotBorder, 1.5),
disk);
}

private static void DrawElevationRing(
private void DrawElevationRing(
DrawingContext context,
double cx,
double cy,
Expand All @@ -182,16 +199,16 @@ private static void DrawElevationRing(
bool dashed = false)
{
var r = (90.0 - Math.Clamp(elevationDeg, 0, 90)) / 90.0 * plotRadius;
var pen = new Pen(new SolidColorBrush(color), thickness);
if (dashed)
pen.DashStyle = DashStyle.Dash;
var pen = dashed
? _renderCache.GetDashedPen(color, thickness)
: _renderCache.GetPen(color, thickness);

context.DrawEllipse(null, pen, new Rect(cx - r, cy - r, r * 2, r * 2));
}

private static void DrawAzimuthSpokes(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
private void DrawAzimuthSpokes(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
{
var pen = new Pen(new SolidColorBrush(palette.SkyPlotSpoke), 1);
var pen = _renderCache.GetPen(palette.SkyPlotSpoke, 1);
for (var az = 0; az < 360; az += 45)
{
if (!SkyPlotControl.TryAzElToPoint(cx, cy, plotRadius, az, 0, out var spokeEnd))
Expand All @@ -201,7 +218,7 @@ private static void DrawAzimuthSpokes(DrawingContext context, double cx, double
}
}

private static void DrawCardinalLabels(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
private void DrawCardinalLabels(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
{
DrawLabel(context, "N", cx, cy - plotRadius - 14, palette);
DrawLabel(context, "S", cx, cy + plotRadius + 4, palette);
Expand All @@ -223,21 +240,15 @@ private void DrawHoverMarker(DrawingContext context, double cx, double cy, doubl
HoverMarkerRadiusPx * 2,
HoverMarkerRadiusPx * 2);
context.DrawEllipse(
new SolidColorBrush(HoverMarkerFill),
new Pen(new SolidColorBrush(HoverMarkerOutline), 2),
_renderCache.GetBrush(HoverMarkerFill),
_renderCache.GetPen(HoverMarkerOutline, 2),
rect);
context.DrawEllipse(null, new Pen(Brushes.White, 1.5), rect);
context.DrawEllipse(null, _renderCache.GetPen(Colors.White, 1.5), rect);
}

private static void DrawLabel(DrawingContext context, string text, double x, double y, UiPalette palette)
private void DrawLabel(DrawingContext context, string text, double x, double y, UiPalette palette)
{
var ft = new FormattedText(
text,
System.Globalization.CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface(FontFamily.Default, FontStyle.Normal, FontWeight.SemiBold),
12,
new SolidColorBrush(palette.SkyPlotLabel));
var ft = _textCache.Get(text, 12, palette.SkyPlotLabel);
context.DrawText(ft, new Point(x, y));
}

Expand Down
22 changes: 22 additions & 0 deletions OscarWatch/Controls/RenderResourceCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal sealed class RenderResourceCache
private readonly Dictionary<Color, SolidColorBrush> _brushes = new();
private readonly Dictionary<(Color Color, double Thickness), Pen> _pens = new();
private readonly Dictionary<(Color Color, double Thickness), Pen> _dashedPens = new();
private readonly Dictionary<(Color Color, double Thickness), Pen> _roundCapPens = new();

/// <summary>
/// Returns a cached SolidColorBrush for the given colour, creating one on first request.
Expand Down Expand Up @@ -56,6 +57,26 @@ public Pen GetDashedPen(Color color, double thickness)
return pen;
}

/// <summary>
/// Returns a cached Pen with LineCap = Round and LineJoin = Round for the given colour and thickness.
/// Used for pass path segments that need rounded line endings.
/// </summary>
public Pen GetRoundCapPen(Color color, double thickness)
{
var key = (color, thickness);
if (!_roundCapPens.TryGetValue(key, out var pen))
{
pen = new Pen(GetBrush(color), thickness)
{
LineCap = PenLineCap.Round,
LineJoin = PenLineJoin.Round
};
_roundCapPens[key] = pen;
}

return pen;
}

/// <summary>
/// Clears all cached brushes and pens. Called when track states composition changes
/// or the colour palette changes (theme switch).
Expand All @@ -65,5 +86,6 @@ public void Clear()
_brushes.Clear();
_pens.Clear();
_dashedPens.Clear();
_roundCapPens.Clear();
}
}