From 9ae97cfaf06d8e0f59e8b780698873807846ddfc Mon Sep 17 00:00:00 2001 From: David Johnson Date: Mon, 6 Jul 2026 16:31:27 +0800 Subject: [PATCH 1/4] perf: complete render-cache adoption in PassPolarPlotControl --- .../PassPolarPlotRenderCacheTests.cs | 97 +++++++++++++++++++ OscarWatch/Controls/FormattedTextCache.cs | 11 ++- OscarWatch/Controls/PassPolarPlotControl.cs | 45 ++++----- OscarWatch/Controls/RenderResourceCache.cs | 22 +++++ 4 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs diff --git a/OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs b/OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs new file mode 100644 index 0000000..1b841bf --- /dev/null +++ b/OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs @@ -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; + +/// +/// **Validates: Requirements 8.1, 8.3** +/// +/// Verifies that returns the same object +/// reference on repeated calls, and that the returned pen has the expected LineCap/LineJoin properties. +/// +public class PassPolarPlotRenderCacheTests +{ + /// + /// GetRoundCapPen returns the same reference for the same colour and thickness on repeated calls. + /// + [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); + } + + /// + /// GetRoundCapPen returns a pen with LineCap = Round and LineJoin = Round. + /// + [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; + } + + /// + /// GetRoundCapPen returns a pen with the correct thickness and brush colour. + /// + [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; + } + + /// + /// Clear() removes round-cap pen entries so the next call creates a fresh pen. + /// + [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); + } + + /// + /// Different colours produce different pen references (not shared incorrectly). + /// + [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); + } +} diff --git a/OscarWatch/Controls/FormattedTextCache.cs b/OscarWatch/Controls/FormattedTextCache.cs index d4aeb69..b8f4959 100644 --- a/OscarWatch/Controls/FormattedTextCache.cs +++ b/OscarWatch/Controls/FormattedTextCache.cs @@ -23,6 +23,15 @@ internal sealed class FormattedTextCache /// Recreates brushes if the palette colours have changed. /// public FormattedText Get(string name, double fontSize, UiPalette palette) + { + return Get(name, fontSize, palette.MapLabelForeground); + } + + /// + /// 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. + /// + public FormattedText Get(string name, double fontSize, Color foreground) { var key = (name, fontSize); if (!_cache.TryGetValue(key, out var text)) @@ -33,7 +42,7 @@ public FormattedText Get(string name, double fontSize, UiPalette palette) FlowDirection.LeftToRight, GetTypeface(), fontSize, - GetLabelBrush(palette)) + new SolidColorBrush(foreground)) { MaxTextWidth = 120 }; diff --git a/OscarWatch/Controls/PassPolarPlotControl.cs b/OscarWatch/Controls/PassPolarPlotControl.cs index 0b4c455..396ae22 100644 --- a/OscarWatch/Controls/PassPolarPlotControl.cs +++ b/OscarWatch/Controls/PassPolarPlotControl.cs @@ -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 PlotDataProperty = AvaloniaProperty.Register(nameof(PlotData)); @@ -82,7 +83,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); @@ -103,11 +104,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)) @@ -162,16 +159,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, @@ -182,16 +179,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)) @@ -201,7 +198,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); @@ -223,21 +220,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)); } diff --git a/OscarWatch/Controls/RenderResourceCache.cs b/OscarWatch/Controls/RenderResourceCache.cs index 84ed668..76727fe 100644 --- a/OscarWatch/Controls/RenderResourceCache.cs +++ b/OscarWatch/Controls/RenderResourceCache.cs @@ -11,6 +11,7 @@ internal sealed class RenderResourceCache private readonly Dictionary _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(); /// /// Returns a cached SolidColorBrush for the given colour, creating one on first request. @@ -56,6 +57,26 @@ public Pen GetDashedPen(Color color, double thickness) return pen; } + /// + /// 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. + /// + 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; + } + /// /// Clears all cached brushes and pens. Called when track states composition changes /// or the colour palette changes (theme switch). @@ -65,5 +86,6 @@ public void Clear() _brushes.Clear(); _pens.Clear(); _dashedPens.Clear(); + _roundCapPens.Clear(); } } From 68d6229614ab9b82f063d2df39d96514fcf97e34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:09:58 +0000 Subject: [PATCH 2/4] Initial plan From c7112b2489ad5e51c8e766348690c086009a0138 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:14:33 +0000 Subject: [PATCH 3/4] fix: clear _textCache and _renderCache on theme change in PassPolarPlotControl --- OscarWatch/Controls/PassPolarPlotControl.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/OscarWatch/Controls/PassPolarPlotControl.cs b/OscarWatch/Controls/PassPolarPlotControl.cs index 396ae22..3c42d40 100644 --- a/OscarWatch/Controls/PassPolarPlotControl.cs +++ b/OscarWatch/Controls/PassPolarPlotControl.cs @@ -48,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); From dd6a10a8ecd278586a9ccaa583cefb4da2a25a1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:19:38 +0000 Subject: [PATCH 4/4] fix: include foreground color in FormattedTextCache key to prevent stale entries --- OscarWatch/Controls/FormattedTextCache.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OscarWatch/Controls/FormattedTextCache.cs b/OscarWatch/Controls/FormattedTextCache.cs index b8f4959..ef6dcf9 100644 --- a/OscarWatch/Controls/FormattedTextCache.cs +++ b/OscarWatch/Controls/FormattedTextCache.cs @@ -10,7 +10,7 @@ namespace OscarWatch.Controls; /// 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; @@ -33,7 +33,7 @@ public FormattedText Get(string name, double fontSize, UiPalette palette) /// public FormattedText Get(string name, double fontSize, Color foreground) { - var key = (name, fontSize); + var key = (name, fontSize, foreground); if (!_cache.TryGetValue(key, out var text)) { text = new FormattedText( @@ -103,7 +103,7 @@ public void Evict(IReadOnlyList 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) {