From 1f5cfe99f2c5c4d3c63e4fe010a2a8da1adefa5a Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:47:30 +0300 Subject: [PATCH] perf(chart): redraw per display frame and cache the plot A drag repainted the chart at about 30 frames per second and late (plot frame spacing p50 33.4 ms, pan step to the next frame p50 24.4 ms), a hover over a still plot re-rendered it about 52 times per second, and every hidden pen axis regenerated its ticks twice per frame. No thread was saturated; the costs that scale with the pen count left little headroom for a 50-pen catalogue. The redraw is paced by the display: the view model raises its redraw signal at once and the view coalesces requests into one TopLevel.RequestAnimationFrame, whose callback invalidates the plot. The 33 ms timer and ChartRedrawSchedule are gone; RequestRedraw is UI thread only, which Avalonia enforces. The plot sits alone inside a decorator that carries a BitmapCache, so a pointer move repaints the overlay over a cached raster and ScottPlot renders only when the plot changes. The cache is on the wrapper because Avalonia 12 never refreshes a BitmapCache set on a self-drawing visual such as AvaPlot. LinearTickGenerator and LogTickGenerator generate nothing while their axis is hidden and keep their last two results, since ScottPlot asks for each axis at two lengths per frame; ChartAxisBinder flips IsDrawn and IsVisible together under Plot.Sync, and only when they change. The history debouncer admits a request on the data scheduler after the Sample and Throttle gates, so a UI-thread request never waits on a starting query, and its Dispose completes rather than disposes the admission subject. The chart posts its data-area width only when it changes. trace-shares.py reports frame spacing, pan latency, compositor rate and tick cost, after an optional warm-up skip. Measured on the demo stand at 8 pens: drag frame spacing p50 17.8 ms, hover about one render per second, follow mode one render per data tick. --- AGENTS.md | 12 +- .../UI/Chart/ChartAxisBinderTests.cs | 146 ++++++- .../ChartHistoryRequestCancellationTests.cs | 2 +- .../ChartHistoryRequestDebouncerTests.cs | 48 +++ .../UI/Chart/ChartTickGeneratorTests.cs | 116 +++++ .../UI/Chart/ChartViewTestBuilder.cs | 19 +- .../UI/Chart/LogTickGeneratorTests.cs | 21 +- .../UI/Chart/TrendChartViewModelTests.cs | 63 +-- .../UI/Chart/TrendChartViewTests.cs | 146 ++++++- .../UI/Legend/TrendLegendViewModelTests.cs | 4 +- .../UI/Legend/TrendLegendViewTests.cs | 4 +- SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs | 38 +- .../Chart/ChartHistoryRequestDebouncer.cs | 6 +- .../SemiPlot.UI/Chart/ChartRedrawSchedule.cs | 48 --- .../SemiPlot.UI/Chart/IDrawnTickGenerator.cs | 11 + .../SemiPlot.UI/Chart/LinearTickGenerator.cs | 43 ++ .../SemiPlot.UI/Chart/LogTickGenerator.cs | 29 +- SemiPlot/SemiPlot.UI/Chart/TickCache.cs | 39 ++ .../SemiPlot.UI/Chart/TrendChartView.axaml | 19 +- .../SemiPlot.UI/Chart/TrendChartView.axaml.cs | 68 ++- .../SemiPlot.UI/Chart/TrendChartViewModel.cs | 10 +- docs/architecture/bench.md | 9 +- docs/architecture/charting.md | 126 +++++- docs/architecture/data-integration.md | 9 +- docs/architecture/testing-strategy.md | 105 ++++- docs/architecture/trend-feature-spec.md | 4 +- docs/architecture/trend-interaction.md | 11 +- docs/architecture/ui-theme.md | 10 +- .../20261008-chart-render-headroom.md | 395 ++++++++++++++++++ scripts/perf/trace-shares.py | 338 ++++++++++++--- 30 files changed, 1607 insertions(+), 292 deletions(-) create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartTickGeneratorTests.cs delete mode 100644 SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs create mode 100644 SemiPlot/SemiPlot.UI/Chart/IDrawnTickGenerator.cs create mode 100644 SemiPlot/SemiPlot.UI/Chart/LinearTickGenerator.cs create mode 100644 SemiPlot/SemiPlot.UI/Chart/TickCache.cs create mode 100644 docs/plans/completed/20261008-chart-render-headroom.md diff --git a/AGENTS.md b/AGENTS.md index 122f533..70e4afd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,11 +137,15 @@ Two projects, split on one axis: needs a container or not. that lands schedules the next 2-60 s ahead, so over `ImmediateScheduler` and a synchronous fake it reads forever once an extent and a drawn pen exist, and `TestScheduler.Start()` never returns after a band read landed. A test builds it over a `TestScheduler` and never calls `Start()` - (`UI/Minimap/MinimapStand.cs`). The chart's redraw hangs nothing: it is a one-shot - schedule, which `ImmediateScheduler` runs inline after a 33 ms sleep. A headless test that realises - the chart still never passes an immediate UI scheduler, because it blocks the dispatcher on every - redraw; it passes a `TestScheduler` or `AvaloniaScheduler.Instance` + (`UI/Minimap/MinimapStand.cs`). A headless test that realises the chart never passes an immediate + UI scheduler, because a history result can then raise the redraw off the UI thread and the view's + `RequestAnimationFrame` throws there; it passes a `TestScheduler` or `AvaloniaScheduler.Instance` (`docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view`). +- The chart view redraws on an animation frame. A test drives one frame as + `Dispatcher.UIThread.RunJobs(); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Dispatcher.UIThread.RunJobs();` + (`ChartViewTestBuilder.DriveOneFrame`) and counts frames through `TrendChartView.ServedFrameCount`, + never per `RunJobs()` call, because a plain `RunJobs()` can also fire the real-time 60 Hz headless + render timer (`docs/architecture/testing-strategy.md#frames-in-a-realised-view`). - A plain `[Fact]` body runs with no `SynchronizationContext`, so an `await` on a `TaskCompletionSource` completed by production code resumes inline on the completing thread. A gate awaited by the test and completed by production code takes diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs index c067617..d3601b6 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs @@ -1,7 +1,6 @@ using AwesomeAssertions; using ScottPlot; -using ScottPlot.TickGenerators; using SemiPlot.Core.Trends; using SemiPlot.UI.Chart; @@ -22,6 +21,9 @@ public sealed class ChartAxisBinderTests private const string FinerExponentMask = "0.00E+0"; private const string FixedPointMask = "0.000"; + private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(10.0); + private static readonly TimeSpan _heldRenderWindow = TimeSpan.FromMilliseconds(200.0); + [Fact] public void EveryAxisTheBinderCreates_IsALeftAxis() { @@ -32,6 +34,55 @@ public void EveryAxisTheBinderCreates_IsALeftAxis() binder.AxesByPenId.Should().HaveCount(3); binder.AxesByPenId.Values.Should().OnlyContain(axis => axis.Edge == Edge.Left); + binder.AxesByPenId.Values.Should().OnlyContain(axis => axis.TickGenerator is LinearTickGenerator); + } + + [Fact] + public void OnlyTheActiveVisiblePensGenerator_IsDrawn() + { + using var plot = new Plot(); + var binder = new ChartAxisBinder(plot); + var pens = Pens(1, 2, 3); + + binder.Apply([Scale(1, isActive: true), Scale(2), Scale(3)], pens); + DrawnPenIds(binder).Should().Equal(1); + + binder.Apply([Scale(1), Scale(2, isActive: true), Scale(3)], pens); + DrawnPenIds(binder).Should().Equal(2); + + pens[2].SetVisibility(false); + binder.Apply([Scale(1), Scale(2, isActive: true), Scale(3)], pens); + DrawnPenIds(binder).Should().BeEmpty(); + } + + [Fact] + public void AHiddenLogPensAxis_StaysLogarithmic() + { + using var plot = new Plot(); + var binder = new ChartAxisBinder(plot); + var pens = Pens(1, 2); + + binder.Apply([LogScale(1, 1e-6, 1e-2, isActive: true), Scale(2)], pens); + binder.Apply([LogScale(1, 1e-6, 1e-2), Scale(2, isActive: true)], pens); + + var axis = binder.AxesByPenId[1]; + axis.IsVisible.Should().BeFalse(); + axis.TickGenerator.Should().BeOfType().Which.IsDrawn.Should().BeFalse(); + axis.Min.Should().BeApproximately(-6.0, DecadeTolerance); + } + + [Fact] + public void HideAxis_StopsTheAxisAndItsGenerator() + { + using var plot = new Plot(); + var binder = new ChartAxisBinder(plot); + binder.Apply([Scale(1, isActive: true)], Pens(1)); + + binder.HideAxis(1); + + var axis = binder.AxesByPenId[1]; + axis.IsVisible.Should().BeFalse(); + axis.TickGenerator.Should().BeOfType().Which.IsDrawn.Should().BeFalse(); } [Fact] @@ -91,7 +142,7 @@ public void ALogScale_SetsTheAxisLimitsInDecades() } [Fact] - public void ALinearScale_GivesALogAxisItsStockGeneratorBack() + public void ALinearScale_GivesALogAxisItsLinearGeneratorBack() { using var plot = new Plot(); var binder = new ChartAxisBinder(plot); @@ -101,7 +152,7 @@ public void ALinearScale_GivesALogAxisItsStockGeneratorBack() binder.Apply([Scale(1, isActive: true)], pens); var axis = binder.AxesByPenId[1]; - axis.TickGenerator.Should().BeOfType(); + axis.TickGenerator.Should().BeOfType().Which.IsDrawn.Should().BeTrue(); axis.Min.Should().Be(0.0); axis.Max.Should().Be(1.0); } @@ -158,6 +209,58 @@ public void TheMinorGridWidth_FollowsTheDrawnAxis() plot.Grid.YAxisStyle.MinorLineStyle.Width.Should().Be(0f, "the drawn axis turned linear"); } + [Fact] + public async Task APanStep_DoesNotWaitForARenderInProgress() + { + using var plot = new Plot(); + var binder = new ChartAxisBinder(plot); + var pens = Pens(1, 2); + binder.Apply([Scale(1, isActive: true), Scale(2)], pens); + PenScale[] panned = + [ + new(1, Min: 0.5, Max: 1.5, ScaleMode.Auto, IsActive: true, IsLogarithmic: false), + new(2, Min: 0.5, Max: 1.5, ScaleMode.Auto, IsActive: false, IsLogarithmic: false) + ]; + + var cancellation = TestContext.Current.CancellationToken; + + using var render = new RenderInProgress(plot); + var panStep = Task.Run(() => binder.Apply(panned, pens), cancellation); + var finished = await Task.WhenAny(panStep, Task.Delay(_lockTimeout, cancellation)); + + finished.Should().BeSameAs(panStep, "a pan step changes no drawn axis, so it takes no plot lock"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AVisibilityChange_WaitsForTheRenderInProgress(bool isShown) + { + using var plot = new Plot(); + var binder = new ChartAxisBinder(plot); + var pens = Pens(1); + binder.Apply([Scale(1, isActive: true)], pens); + if (isShown) + { + pens[1].SetVisibility(false); + binder.Apply([Scale(1, isActive: true)], pens); + } + + pens[1].SetVisibility(isShown); + var cancellation = TestContext.Current.CancellationToken; + Task change; + using (new RenderInProgress(plot)) + { + change = Task.Run(() => binder.Apply([Scale(1, isActive: true)], pens), cancellation); + var finished = await Task.WhenAny(change, Task.Delay(_heldRenderWindow, cancellation)); + + finished.Should().NotBeSameAs(change, "the axis and its generator change together between two renders"); + } + + await change.WaitAsync(_lockTimeout, cancellation); + binder.AxesByPenId[1].IsVisible.Should().Be(isShown); + } + private static List MajorTicks(IYAxis axis) { using var paint = Paint.NewDisposablePaint(); @@ -176,6 +279,13 @@ private static IEnumerable VisibleAxes(ChartAxisBinder binder) return binder.AxesByPenId.Values.Where(axis => axis.IsVisible); } + private static IEnumerable DrawnPenIds(ChartAxisBinder binder) + { + return binder.AxesByPenId + .Where(entry => entry.Value.TickGenerator is IDrawnTickGenerator { IsDrawn: true }) + .Select(entry => entry.Key); + } + private static Dictionary Pens(params int[] penIds) { return penIds.ToDictionary( @@ -192,4 +302,34 @@ private static PenScale LogScale(int penId, double min, double max, bool isActiv { return new PenScale(penId, min, max, ScaleMode.Manual, isActive, IsLogarithmic: true); } + + /// Holds Plot.Sync on a thread of its own, as ScottPlot's render does, until disposed. + private sealed class RenderInProgress : IDisposable + { + private readonly ManualResetEventSlim _entered = new(); + private readonly ManualResetEventSlim _finished = new(); + private readonly Thread _thread; + + public RenderInProgress(Plot plot) + { + _thread = new Thread(() => + { + lock (plot.Sync) + { + _entered.Set(); + _finished.Wait(); + } + }); + _thread.Start(); + _entered.Wait(); + } + + public void Dispose() + { + _finished.Set(); + _thread.Join(); + _entered.Dispose(); + _finished.Dispose(); + } + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs index a7c3fe7..f9640e9 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs @@ -95,7 +95,7 @@ public async Task AGestureEndingOnTheWindowBeingReadLeavesTheReadRunning() debouncer.Request(RequestAtNotch(notch)); } - scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks + 1); var running = queries.Started.Should().ContainSingle().Which; running.Request.FromUtc.Should().Be(From.AddSeconds(LastNotchBeforeTheFirstTick)); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs index 0a87ae7..209e6a5 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs @@ -1,3 +1,4 @@ +using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; @@ -8,6 +9,7 @@ using Microsoft.Reactive.Testing; using SemiPlot.Core.Trends; +using SemiPlot.UI.Chart; using Xunit; @@ -20,6 +22,9 @@ namespace SemiPlot.Tests.Unit.UI.Chart; [Trait("Category", "Unit")] public sealed class ChartHistoryRequestDebouncerTests { + // Well under the query's own block, so a request that waited for the query cannot pass. + private static readonly TimeSpan _promptReturn = TimeSpan.FromSeconds(5.0); + [Fact] public void RapidRequests_CollapseToOneTrailingQuery() { @@ -188,6 +193,9 @@ public void AContinuousGestureFetchesAtTheCapInterval() debouncer.Request(RequestAtNotch(notch)); } + // The 2000 ms tick's admission runs one tick after it, on the data scheduler. + scheduler.AdvanceBy(1); + queryCount.Should().Be(5); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); @@ -195,6 +203,46 @@ public void AContinuousGestureFetchesAtTheCapInterval() queryCount.Should().Be(6); } + // A debounce window no request outlives leaves the sample head as the only admission. The query blocks on + // a real thread before it returns its task, as a provider that opens its connection synchronously does. + [Fact] + public async Task RequestReturnsWhileAPacedAdmissionIsStartingItsQuery() + { + using var queryRelease = new ManualResetEventSlim(); + var queryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var debouncer = new ChartHistoryRequestDebouncer( + (request, cancellation) => + { + queryStarted.TrySetResult(); + queryRelease.Wait(TestDeadline, cancellation); + + return Task.FromResult(Ok(request)); + }, + (_, _) => { }, + _ => { }, + TimeSpan.FromHours(1.0), + TimeSpan.FromMilliseconds(10), + DefaultScheduler.Instance, + ImmediateScheduler.Instance); + + try + { + debouncer.Request(RequestAtNotch(1)); + await queryStarted.Task.WaitAsync(TestDeadline, TestContext.Current.CancellationToken); + + var secondRequest = Task.Run(() => debouncer.Request(RequestAtNotch(2)), TestContext.Current.CancellationToken); + var returnedFirst = await Task.WhenAny( + secondRequest, + Task.Delay(_promptReturn, TestContext.Current.CancellationToken)); + + returnedFirst.Should().BeSameAs(secondRequest); + } + finally + { + queryRelease.Set(); + } + } + [Fact] public void TheSameWindowRequestedTwiceQueriesOnce() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartTickGeneratorTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartTickGeneratorTests.cs new file mode 100644 index 0000000..b52e29a --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartTickGeneratorTests.cs @@ -0,0 +1,116 @@ +using AwesomeAssertions; + +using ScottPlot; + +using SemiPlot.UI.Chart; + +using Xunit; + +namespace SemiPlot.Tests.Unit.UI.Chart; + +[Collection(ProcessGlobalStateCollection.Name)] +[Trait("Component", "UI")] +[Trait("Area", "Chart")] +[Trait("Category", "Unit")] +public sealed class ChartTickGeneratorTests +{ + private const float AxisPixels = 400f; + private const float PanelPixels = 380f; + + private static readonly CoordinateRange _linearRange = new(0.0, 100.0); + private static readonly CoordinateRange _pannedLinearRange = new(10.0, 110.0); + private static readonly CoordinateRange _decades = new(-6.0, -1.0); + + public static TheoryData Generators => [nameof(LinearTickGenerator), nameof(LogTickGenerator)]; + + [Theory] + [MemberData(nameof(Generators))] + public void AHiddenGenerator_GeneratesNothing(string generatorName) + { + var generator = Create(generatorName); + + Regenerate(generator, RangeFor(generator), AxisPixels); + + generator.Ticks.Should().BeEmpty(); + } + + [Theory] + [MemberData(nameof(Generators))] + public void AGeneratorHiddenAfterDrawing_PublishesNoTicks(string generatorName) + { + var generator = Create(generatorName); + generator.IsDrawn = true; + Regenerate(generator, RangeFor(generator), AxisPixels); + var drawn = generator.Ticks; + + generator.IsDrawn = false; + Regenerate(generator, RangeFor(generator), PanelPixels); + + drawn.Should().NotBeEmpty(); + generator.Ticks.Should().BeEmpty(); + } + + [Theory] + [MemberData(nameof(Generators))] + public void TheTwoLengthsOfOneFrame_GenerateTwice(string generatorName) + { + var generator = Create(generatorName); + generator.IsDrawn = true; + var range = RangeFor(generator); + + Regenerate(generator, range, AxisPixels); + var atAxis = generator.Ticks; + Regenerate(generator, range, PanelPixels); + var atPanel = generator.Ticks; + Regenerate(generator, range, AxisPixels); + var atAxisAgain = generator.Ticks; + Regenerate(generator, range, PanelPixels); + + atAxis.Should().NotBeEmpty().And.NotBeSameAs(atPanel); + atAxisAgain.Should().BeSameAs(atAxis); + generator.Ticks.Should().BeSameAs(atPanel); + } + + [Fact] + public void ANewRange_Regenerates() + { + var generator = new LinearTickGenerator { IsDrawn = true }; + Regenerate(generator, _linearRange, AxisPixels); + var before = generator.Ticks; + + Regenerate(generator, _pannedLinearRange, AxisPixels); + + generator.Ticks.Should().NotBeSameAs(before); + generator.Ticks.Should().OnlyContain(tick => _pannedLinearRange.Contains(tick.Position)); + } + + [Theory] + [MemberData(nameof(Generators))] + public void AGeneratorHiddenThenDrawn_TicksAgain(string generatorName) + { + var generator = Create(generatorName); + Regenerate(generator, RangeFor(generator), AxisPixels); + generator.Ticks.Should().BeEmpty(); + + generator.IsDrawn = true; + Regenerate(generator, RangeFor(generator), AxisPixels); + + generator.Ticks.Should().Contain(tick => tick.IsMajor); + } + + private static IDrawnTickGenerator Create(string generatorName) + { + return generatorName == nameof(LogTickGenerator) ? new LogTickGenerator() : new LinearTickGenerator(); + } + + private static CoordinateRange RangeFor(IDrawnTickGenerator generator) + { + return generator is LogTickGenerator ? _decades : _linearRange; + } + + private static void Regenerate(ITickGenerator generator, CoordinateRange range, float pixels) + { + using var paint = Paint.NewDisposablePaint(); + generator.Regenerate(range, Edge.Left, new PixelLength(pixels), paint, new LabelStyle()); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartViewTestBuilder.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartViewTestBuilder.cs index 583c156..eca44f4 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartViewTestBuilder.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartViewTestBuilder.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Headless; using Avalonia.Threading; using Avalonia.VisualTree; @@ -75,7 +76,18 @@ internal static ShownChart ShowChart(TrendChartViewModel viewModel) viewModel.Plot.RenderManager.LastRender.Layout.DataRect.HasArea.Should().BeTrue( "the view's pixel-to-time maths reads the last render's data area"); - return new ShownChart(window, plotControl); + return new ShownChart(window, plotControl) + { + View = view + }; + } + + /// One render pulse: docs/architecture/testing-strategy.md#frames-in-a-realised-view. + internal static void DriveOneFrame() + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); } internal static TrendChartViewModel CreateLoadedViewModel() @@ -88,8 +100,7 @@ internal static TrendChartViewModel CreateLoadedViewModel() scheduler, ImmediateScheduler.Instance, _batchWindow); - // AvaloniaScheduler, as in production, and every test here disposes the view model: - // docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view. + // AvaloniaScheduler, as in production: docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view. var viewModel = new TrendChartViewModel( coordinator, scheduler, @@ -112,6 +123,8 @@ internal static TrendChartViewModel CreateLoadedViewModel() /// The window a test showed; disposing it closes the window. internal sealed record ShownChart(Window Window, AvaPlot PlotControl) : IDisposable { + public required TrendChartView View { get; init; } + public void Dispose() { Window.Close(); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/LogTickGeneratorTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/LogTickGeneratorTests.cs index 6138c83..d55ffb6 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/LogTickGeneratorTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/LogTickGeneratorTests.cs @@ -16,14 +16,13 @@ namespace SemiPlot.Tests.Unit.UI.Chart; public sealed class LogTickGeneratorTests { private const float AxisPixels = 400f; - private const float PanelPixels = 380f; private const string IntegerMask = "0"; private const string InvariantCultureName = ""; [Fact] public void AMaskTooCoarseForTheSpacing_LeavesEveryMajorLabelDistinct() { - var generator = new LogTickGenerator { Mask = IntegerMask }; + var generator = new LogTickGenerator { Mask = IntegerMask, IsDrawn = true }; var range = new CoordinateRange(Math.Log10(100.0), Math.Log10(100.5)); Regenerate(generator, range, AxisPixels); @@ -43,7 +42,7 @@ public void AMaskTooCoarseForTheRange_LabelsOnlyTheMajorsItReadsBack( double bottom, double top, string mask, double[] labelledValues, string[] labels) { using var culture = new CultureScope(InvariantCultureName); - var generator = new LogTickGenerator { Mask = mask }; + var generator = new LogTickGenerator { Mask = mask, IsDrawn = true }; Regenerate(generator, new CoordinateRange(Math.Log10(bottom), Math.Log10(top)), AxisPixels); @@ -53,22 +52,6 @@ public void AMaskTooCoarseForTheRange_LabelsOnlyTheMajorsItReadsBack( labelledValues, (actual, expected) => Math.Abs(actual - expected) < expected * 1e-9); } - [Fact] - public void TheTwoLengthsOfOneFrame_ReuseTheirTicks() - { - var generator = new LogTickGenerator(); - var range = new CoordinateRange(-6.0, -1.0); - - Regenerate(generator, range, AxisPixels); - var atAxis = generator.Ticks; - Regenerate(generator, range, PanelPixels); - var atPanel = generator.Ticks; - Regenerate(generator, range, AxisPixels); - - generator.Ticks.Should().BeSameAs(atAxis); - atPanel.Should().NotBeSameAs(atAxis); - } - private static void Regenerate(LogTickGenerator generator, CoordinateRange range, float pixels) { using var paint = Paint.NewDisposablePaint(); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs index c001b06..a2ea18c 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs @@ -36,7 +36,6 @@ namespace SemiPlot.Tests.Unit.UI.Chart; public sealed class TrendChartViewModelTests { private static readonly TimeSpan _testDeadline = TimeSpan.FromSeconds(10.0); - private static readonly TimeSpan _redrawDelay = TimeSpan.FromMilliseconds(33.0); private static readonly DateTime _from = new(2026, 6, 15, 8, 0, 0, DateTimeKind.Utc); private static readonly DateTime _to = new(2026, 6, 15, 9, 0, 0, DateTimeKind.Utc); private static readonly TimeSpan _realtimeInterval = TimeSpan.FromMilliseconds(10.0); @@ -342,77 +341,53 @@ public void InitialScale_RequestsARedraw() using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++); viewModel.RestoreInitialScale(); - scheduler.AdvanceBy(BatchWindow.Ticks * 2); redraws.Should().Be(1); } [AvaloniaFact] - public void AnIdleChartSchedulesNoRedraw() + public void AnIdleChartRaisesNoRedraw_AndARequestRaisesOneAtOnce() { var uiScheduler = new QueueReadingScheduler(); using var viewModel = CreateChartOn(uiScheduler); var redraws = 0; using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++); - uiScheduler.HasQueuedWork.Should().BeFalse("a chart with nothing to redraw schedules nothing"); - - viewModel.SetDeltaModeEnabled(true); - uiScheduler.AdvanceBy(_redrawDelay.Ticks); - - redraws.Should().Be(1); - uiScheduler.HasQueuedWork.Should().BeFalse("the redraw was one emission, not a timer left running"); - } - - [AvaloniaFact] - public void AChartNoViewSubscribesToSchedulesNoRedraw() - { - var uiScheduler = new QueueReadingScheduler(); - using var viewModel = CreateChartOn(uiScheduler); + redraws.Should().Be(0, "a chart with nothing to redraw raises nothing"); viewModel.SetDeltaModeEnabled(true); - uiScheduler.HasQueuedWork.Should().BeFalse("no view would draw the frame"); + redraws.Should().Be(1, "the request is raised inside the call that made it"); + uiScheduler.HasQueuedWork.Should().BeFalse("the redraw leaves nothing on the UI scheduler"); } [AvaloniaFact] - public void DisposingTheChartCancelsTheScheduledRedraw() + public void DisposingTheChartCompletesTheRedrawSignal() { - var uiScheduler = new QueueReadingScheduler(); - var viewModel = CreateChartOn(uiScheduler); + var viewModel = CreateChartOn(new QueueReadingScheduler()); var redraws = 0; - using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++); - viewModel.SetDeltaModeEnabled(true); + var isCompleted = false; + using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++, () => isCompleted = true); viewModel.Dispose(); - uiScheduler.HasQueuedWork.Should().BeFalse("the disposed chart leaves nothing on the UI scheduler"); - var advance = () => uiScheduler.AdvanceBy(_redrawDelay.Ticks * 2); - advance.Should().NotThrow(); + isCompleted.Should().BeTrue("the view clears a pending frame when the signal completes"); redraws.Should().Be(0); } [AvaloniaFact] - public void ABurstOfRequestsRedrawsOncePerSpanAndOnceAfterTheLastRequest() + public void ABurstOfRequestsRaisesOneSignalPerRequest() { - var uiScheduler = new QueueReadingScheduler(); - using var viewModel = CreateChartOn(uiScheduler); - var redrawTimes = new List(); - using var subscription = viewModel.RedrawRequested - .Subscribe(_ => redrawTimes.Add(TimeSpan.FromTicks(uiScheduler.Clock))); + using var viewModel = CreateChartOn(new QueueReadingScheduler()); + var redraws = 0; + using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++); for (var request = 0; request <= 10; request++) { viewModel.SetDeltaModeEnabled(request % 2 == 0); - uiScheduler.AdvanceBy(TimeSpan.FromMilliseconds(10.0).Ticks); } - uiScheduler.AdvanceBy(TimeSpan.FromSeconds(1.0).Ticks); - - redrawTimes.Should().Equal( - TimeSpan.FromMilliseconds(33.0), - TimeSpan.FromMilliseconds(73.0), - TimeSpan.FromMilliseconds(113.0)); + redraws.Should().Be(11, "the view, not the view model, joins the requests of one frame"); } [AvaloniaFact] @@ -1419,7 +1394,7 @@ public async Task AReadForAnOlderPenSetLeavesTheNewPensReadRunning() var penOneRead = provider.HistoryGate; provider.HistoryGate = new(); viewModel.AddPen(new Pen(2, "Pen 2", ["Group A"], "#00ff00")); - scheduler.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks + 1); await ReleaseAndAwaitResults(viewModel, 1, () => penOneRead.SetResult(StaleEnvelopes())); await AwaitQueryCount(provider, 2); @@ -1961,8 +1936,8 @@ public void AFailedHistoryQueryReachesTheMessagePanelAndStillRedraws() provider.FailHistory = true; chart.RequestInitialHistory(); - // Past the debounce window and the 33 ms the recovery redraw waits, and short of the - // 400 ms cap interval, which would admit a second query for the same window. + // Past the debounce window and short of the 400 ms cap interval, which would admit a second query for + // the same window. scheduler.AdvanceBy(TimeSpan.FromMilliseconds(200.0).Ticks); messagePanel.Entries.Should().ContainSingle() @@ -2087,8 +2062,8 @@ public void AThrowOutOfTheMessagePanel_IsLoggedAndTheRealtimeApplyContinues() valueWhenTheReportThrew, "the batches after the failed report still reach the pen"); } - // Both schedulers are virtual, unlike CreateViewModel's: these tests count the redraws a request schedules - // 33 ms ahead, and they drive the realtime stream by advancing time. + // Both schedulers are virtual, unlike CreateViewModel's: these tests drive the realtime stream and the + // history debouncer by advancing time. private static (TrendChartViewModel ViewModel, TestScheduler Scheduler, MessagePanelViewModel Panel) CreateReportingViewModel(TimeSpan realtimeInterval, out FakeDataProvider provider) { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs index 1f54658..69d63ea 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs @@ -23,6 +23,11 @@ using Xunit; +using static SemiPlot.Tests.Unit.UI.Chart.ChartViewTestBuilder; + +using BitmapCache = Avalonia.Media.BitmapCache; + + namespace SemiPlot.Tests.Unit.UI.Chart; [Collection(ProcessGlobalStateCollection.Name)] @@ -56,6 +61,23 @@ public void RenderedFrame_ReportsItsDataAreaWidthToTheViewModel() viewModel.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MaxColumns); } + [AvaloniaFact] + public void TwoRendersAtOneWidth_ReportTheWidthOnce() + { + using var viewModel = CreateViewModel(); + _ = new TrendChartView { DataContext = viewModel }; + viewModel.Plot.RenderInMemory(320, 240); + Dispatcher.UIThread.RunJobs(); + viewModel.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MinColumns); + + // A width reported past the view marks whether the second render posts its width again. + viewModel.ReportDataAreaWidth(2600.0); + viewModel.Plot.RenderInMemory(320, 240); + Dispatcher.UIThread.RunJobs(); + + viewModel.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MaxColumns); + } + [AvaloniaFact] public void DetachedViewModel_NoLongerReceivesWidthReports() { @@ -180,13 +202,133 @@ public void EmptyCatalogueMessage_ShowsWithNoPensAndWithdrawsWhenOneArrives() } } + [AvaloniaFact] + public void ABurstOfRedrawRequests_JoinsOneFrame() + { + using var viewModel = CreateLoadedViewModel(); + using var shown = ShowChart(viewModel); + DriveOneFrame(); + var framesBefore = shown.View.ServedFrameCount; + + for (var request = 0; request < 10; request++) + { + viewModel.SetDeltaModeEnabled(request % 2 == 0); + } + + DriveOneFrame(); + + shown.View.ServedFrameCount.Should().Be(framesBefore + 1, "the requests before one frame join it"); + + DriveOneFrame(); + + shown.View.ServedFrameCount.Should().Be(framesBefore + 1, "a frame nothing requested serves nothing"); + } + + [AvaloniaFact] + public void ARequestOnADetachedView_LeavesTheNextRequestAfterAttachingServed() + { + using var viewModel = CreateLoadedViewModel(); + var view = new TrendChartView { DataContext = viewModel }; + viewModel.SetDeltaModeEnabled(true); + var window = new Window { Width = 900, Height = 600 }; + try + { + window.Show(); + DriveOneFrame(); + + window.Content = view; + DriveOneFrame(); + DriveOneFrame(); + var framesAfterAttaching = view.ServedFrameCount; + viewModel.SetDeltaModeEnabled(false); + DriveOneFrame(); + + framesAfterAttaching.Should().Be(1, "the view requests its first frame when it loads"); + view.ServedFrameCount.Should().Be(2, "the request on the detached view left no frame pending"); + } + finally + { + window.Close(); + } + } + + // A thread of its own rather than an await, so the dispatcher runs no frame while the request is refused. + // docs/architecture/charting.md#the-frame-paced-redraw + [AvaloniaFact] + public void ARequestRefusedOffTheUiThread_LeavesTheViewsNextFrameServed() + { + using var scope = ThemeProbe.PreserveVariant(); + var application = Application.Current!; + application.RequestedThemeVariant = ThemeVariant.Light; + using var viewModel = CreateLoadedViewModel(); + using var shown = ShowChart(viewModel); + DriveOneFrame(); + var framesBefore = shown.View.ServedFrameCount; + Exception? refusal = null; + + var offTheUiThread = new Thread(() => refusal = Record.Exception(() => viewModel.SetActivePen(1))); + offTheUiThread.Start(); + offTheUiThread.Join(); + application.RequestedThemeVariant = ThemeVariant.Dark; + DriveOneFrame(); + + refusal.Should().BeOfType("RequestAnimationFrame verifies the UI thread"); + shown.View.ServedFrameCount.Should().Be(framesBefore + 1, "the refused request left no frame pending"); + } + + [AvaloniaFact] + public void AFramePendingWhenTheViewModelIsDisposed_ServesNothing() + { + var viewModel = CreateLoadedViewModel(); + using var shown = ShowChart(viewModel); + DriveOneFrame(); + var framesBefore = shown.View.ServedFrameCount; + viewModel.SetDeltaModeEnabled(true); + + viewModel.Dispose(); + var drive = DriveOneFrame; + + drive.Should().NotThrow(); + shown.View.ServedFrameCount.Should().Be(framesBefore); + } + + [AvaloniaFact] + public void AFramePendingWhenTheViewModelIsReplaced_ServesNothing() + { + using var viewModel = CreateLoadedViewModel(); + using var replacement = CreateLoadedViewModel(); + using var shown = ShowChart(viewModel); + DriveOneFrame(); + var framesBefore = shown.View.ServedFrameCount; + viewModel.SetDeltaModeEnabled(true); + + shown.View.DataContext = replacement; + DriveOneFrame(); + + shown.View.ServedFrameCount.Should().Be(framesBefore); + } + + // docs/architecture/charting.md#hover-and-the-plot-cache + [AvaloniaFact] + public void ThePlotIsCachedThroughItsOnlyAncestor() + { + using var viewModel = CreateLoadedViewModel(); + using var shown = ShowChart(viewModel); + + var layer = shown.View.FindControl("PlotLayer"); + + layer.Should().NotBeNull(); + layer!.CacheMode.Should().BeOfType(); + layer.Child.Should().BeSameAs(shown.PlotControl); + shown.PlotControl.CacheMode.Should().BeNull(); + } + private static ScottPlot.Color FigureBackgroundUnder(ThemeVariant variant) { return ThemeProbe.PlotColour("AppPanelBackgroundBrush", variant); } - // Both schedulers are virtual here, unlike the other chart tests: - // docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view. + // Both schedulers are virtual: docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view. private static TrendChartViewModel CreateViewModel() { var scheduler = new TestScheduler(); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs index 7b7eec3..3dd8b6e 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs @@ -534,7 +534,9 @@ private void LoadInitialHistory(TrendChartViewModel chart, DateTime from, DateTi { chart.Navigation.TrackDataExtents(from, to); chart.RequestInitialHistory(); - _scheduler.AdvanceBy(_historyDebounceWindow.Ticks + 1); + + // The admission and the delivery each land one tick after the step that schedules them. + _scheduler.AdvanceBy(_historyDebounceWindow.Ticks + 2); } private static TrendLegendRowViewModel SingleRow(TrendLegendViewModel legend, int penId) diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs index 2691c0e..c5a17f4 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs @@ -632,7 +632,9 @@ private void LoadInitialHistory(TrendChartViewModel chart) { chart.Navigation.TrackDataExtents(_from, _to); chart.RequestInitialHistory(); - _scheduler.AdvanceBy(_historyDebounceWindow.Ticks + 1); + + // The admission and the delivery each land one tick after the step that schedules them. + _scheduler.AdvanceBy(_historyDebounceWindow.Ticks + 2); } private TrendChartViewModel CreateChart() diff --git a/SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs b/SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs index 015a9a6..97de3a9 100644 --- a/SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs +++ b/SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs @@ -1,5 +1,4 @@ using ScottPlot; -using ScottPlot.TickGenerators; using SemiPlot.Core.Trends; @@ -25,7 +24,7 @@ public void HideAxis(int penId) { if (_axesByPenId.TryGetValue(penId, out var axis)) { - axis.IsVisible = false; + SetDrawn(axis, false); } } @@ -58,22 +57,40 @@ public void Apply( lock (_plot.Sync) { axis.TickGenerator = scale.IsLogarithmic - ? new LogTickGenerator { Mask = mask } - : new NumericAutomatic(); + ? new LogTickGenerator { Mask = mask, IsDrawn = axis.IsVisible } + : new LinearTickGenerator { IsDrawn = axis.IsVisible }; SetLimits(axis, scale); RefreshMinorGridWidth(); } } - axis.IsVisible = scale.IsActive && pen is { IsVisible: true }; + var isDrawn = scale.IsActive && pen is { IsVisible: true }; + SetDrawn(axis, isDrawn); - if (axis.IsVisible) + if (isDrawn) { DrawGridFrom(axis); } } } + // docs/architecture/charting.md#ticks-for-the-drawn-axis-only + private void SetDrawn(IYAxis axis, bool isDrawn) + { + var generator = (IDrawnTickGenerator)axis.TickGenerator; + + if (axis.IsVisible == isDrawn && generator.IsDrawn == isDrawn) + { + return; + } + + lock (_plot.Sync) + { + generator.IsDrawn = isDrawn; + axis.IsVisible = isDrawn; + } + } + private void SetLimits(IYAxis axis, PenScale scale) { if (scale.IsLogarithmic) @@ -135,6 +152,13 @@ private IYAxis ResolveAxis(int penId) private IYAxis CreateAxis() { - return _axesByPenId.Count == 0 ? _plot.Axes.Left : _plot.Axes.AddLeftAxis(); + var axis = _axesByPenId.Count == 0 ? _plot.Axes.Left : _plot.Axes.AddLeftAxis(); + + lock (_plot.Sync) + { + axis.TickGenerator = new LinearTickGenerator { IsDrawn = axis.IsVisible }; + } + + return axis; } } diff --git a/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs b/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs index 29a3e6b..cff7a15 100644 --- a/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs +++ b/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs @@ -58,6 +58,7 @@ public ChartHistoryRequestDebouncer( var emissions = trailing .Merge(paced) + .ObserveOn(dataScheduler) .Subscribe(admission => Admit(admission.Pushed, admission.EndsGesture)); var queries = _admitted @@ -85,7 +86,10 @@ public void Dispose() _isDisposed = true; _subscription.Dispose(); _requests.Dispose(); - _admitted.Dispose(); + + // Start can still push from the data or the completion thread past its disposed check; a completed + // subject drops that push, where a disposed one would throw on that thread. + _admitted.OnCompleted(); lock (_gate) { diff --git a/SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs b/SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs deleted file mode 100644 index b659b7a..0000000 --- a/SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Reactive; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; - -namespace SemiPlot.UI.Chart; - -// docs/architecture/charting.md#module-layout-avalonia-views--view-models--core-models -public sealed class ChartRedrawSchedule : IDisposable -{ - private static readonly TimeSpan _redrawDelay = TimeSpan.FromMilliseconds(33); - private readonly IScheduler _uiScheduler; - private readonly Subject _redraws = new(); - private IDisposable _scheduledRedraw = Disposable.Empty; - private bool _isRedrawScheduled; - - public ChartRedrawSchedule(IScheduler uiScheduler) - { - _uiScheduler = uiScheduler; - Redraws = _redraws.AsObservable(); - } - - public IObservable Redraws { get; } - - public void Request() - { - if (_isRedrawScheduled || !_redraws.HasObservers) - { - return; - } - - _isRedrawScheduled = true; - _scheduledRedraw = _uiScheduler.Schedule(_redrawDelay, Emit); - } - - public void Dispose() - { - _scheduledRedraw.Dispose(); - _redraws.Dispose(); - } - - private void Emit() - { - _isRedrawScheduled = false; - _redraws.OnNext(Unit.Default); - } -} diff --git a/SemiPlot/SemiPlot.UI/Chart/IDrawnTickGenerator.cs b/SemiPlot/SemiPlot.UI/Chart/IDrawnTickGenerator.cs new file mode 100644 index 0000000..6119545 --- /dev/null +++ b/SemiPlot/SemiPlot.UI/Chart/IDrawnTickGenerator.cs @@ -0,0 +1,11 @@ +using ScottPlot; + +namespace SemiPlot.UI.Chart; + +// docs/architecture/charting.md#ticks-for-the-drawn-axis-only +/// A pen axis's tick generator, which generates nothing while its axis is not drawn. +public interface IDrawnTickGenerator : ITickGenerator +{ + /// Written on the UI thread by ChartAxisBinder, read on the render thread by Regenerate. + bool IsDrawn { get; set; } +} diff --git a/SemiPlot/SemiPlot.UI/Chart/LinearTickGenerator.cs b/SemiPlot/SemiPlot.UI/Chart/LinearTickGenerator.cs new file mode 100644 index 0000000..87b2aa8 --- /dev/null +++ b/SemiPlot/SemiPlot.UI/Chart/LinearTickGenerator.cs @@ -0,0 +1,43 @@ +using ScottPlot; +using ScottPlot.TickGenerators; + +namespace SemiPlot.UI.Chart; + +/// ScottPlot's linear ticks for a drawn axis, reused while the range, the length and the font stand. +public sealed class LinearTickGenerator : IDrawnTickGenerator +{ + private readonly NumericAutomatic _numeric = new(); + private readonly TickCache _cache = new(); + + public bool IsDrawn { get; set; } + + public Tick[] Ticks { get; private set; } = []; + + public int MaxTickCount + { + get => _numeric.MaxTickCount; + set => _numeric.MaxTickCount = value; + } + + public void Regenerate(CoordinateRange range, Edge edge, PixelLength size, Paint paint, LabelStyle labelStyle) + { + if (!IsDrawn) + { + Ticks = []; + return; + } + + var key = new TickKey(range.Min, range.Max, size.Length, labelStyle.FontSize); + + if (!_cache.TryFind(key, out var ticks)) + { + _numeric.Regenerate(range, edge, size, paint, labelStyle); + ticks = _numeric.Ticks; + _cache.Add(key, ticks); + } + + Ticks = ticks; + } + + private readonly record struct TickKey(double Min, double Max, float PixelLength, float FontSize); +} diff --git a/SemiPlot/SemiPlot.UI/Chart/LogTickGenerator.cs b/SemiPlot/SemiPlot.UI/Chart/LogTickGenerator.cs index 5eec0e0..2e3d59a 100644 --- a/SemiPlot/SemiPlot.UI/Chart/LogTickGenerator.cs +++ b/SemiPlot/SemiPlot.UI/Chart/LogTickGenerator.cs @@ -7,43 +7,40 @@ namespace SemiPlot.UI.Chart; /// Ticks of a log10 axis whose limits are in decades; majors read under the pen's mask. -public sealed class LogTickGenerator : ITickGenerator +public sealed class LogTickGenerator : IDrawnTickGenerator { private const double LabelTolerance = 0.01; private const NumberStyles LabelStyles = NumberStyles.Float | NumberStyles.AllowThousands; - // Regenerate alone touches the cache and publishes Ticks; it runs on the render thread under Plot.Sync. - private CachedTicks? _recent; - private CachedTicks? _older; + private readonly TickCache _cache = new(); // Written on the UI thread by ChartAxisBinder.Apply, read on the render thread in Regenerate; a reference // write is atomic, so a frame sees either mask whole. public string? Mask { get; set; } + public bool IsDrawn { get; set; } + public Tick[] Ticks { get; private set; } = []; public int MaxTickCount { get; set; } = int.MaxValue; public void Regenerate(CoordinateRange range, Edge edge, PixelLength size, Paint paint, LabelStyle labelStyle) { - var key = new TickKey(range.Min, range.Max, size.Length, Mask); - - if (_recent is { } recent && recent.Key == key) + if (!IsDrawn) { - Ticks = recent.Ticks; + Ticks = []; return; } - if (_older is { } older && older.Key == key) + var key = new TickKey(range.Min, range.Max, size.Length, Mask); + + if (!_cache.TryFind(key, out var ticks)) { - (_recent, _older) = (older, _recent); - Ticks = older.Ticks; - return; + ticks = Generate(key); + _cache.Add(key, ticks); } - var generated = Generate(key); - (_recent, _older) = (new CachedTicks(key, generated), _recent); - Ticks = generated; + Ticks = ticks; } /// An axis runs in decades while this generator ticks it; ScottPlot leaves an unbound axis null. @@ -93,6 +90,4 @@ private static bool ReadsBackAs(string label, double value) } private readonly record struct TickKey(double MinLog, double MaxLog, float PixelLength, string? Mask); - - private sealed record CachedTicks(TickKey Key, Tick[] Ticks); } diff --git a/SemiPlot/SemiPlot.UI/Chart/TickCache.cs b/SemiPlot/SemiPlot.UI/Chart/TickCache.cs new file mode 100644 index 0000000..59f3df3 --- /dev/null +++ b/SemiPlot/SemiPlot.UI/Chart/TickCache.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +using ScottPlot; + +namespace SemiPlot.UI.Chart; + +/// The last two tick results of one generator, used only by its Regenerate under Plot.Sync. +internal sealed class TickCache + where TKey : struct, IEquatable +{ + private Entry? _recent; + private Entry? _older; + + public bool TryFind(TKey key, [NotNullWhen(true)] out Tick[]? ticks) + { + if (_recent is { } recent && recent.Key.Equals(key)) + { + ticks = recent.Ticks; + return true; + } + + if (_older is { } older && older.Key.Equals(key)) + { + (_recent, _older) = (older, _recent); + ticks = older.Ticks; + return true; + } + + ticks = null; + return false; + } + + public void Add(TKey key, Tick[] ticks) + { + (_recent, _older) = (new Entry(key, ticks), _recent); + } + + private sealed record Entry(TKey Key, Tick[] Ticks); +} diff --git a/SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml b/SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml index fb417cf..f42cd35 100644 --- a/SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml +++ b/SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml @@ -12,13 +12,18 @@ x:DataType="chart:TrendChartViewModel"> - - - - - - - + + + + + + + + + + + + RepositionCursorOverlay(); } + /// Animation-frame callbacks that served a redraw request since the view was built. + internal int ServedFrameCount { get; private set; } + protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); @@ -77,6 +82,8 @@ protected override void OnLoaded(RoutedEventArgs e) protected override void OnUnloaded(RoutedEventArgs e) { + ClearPendingFrame(); + if (Application.Current is { } application) { application.ActualThemeVariantChanged -= OnActualThemeVariantChanged; @@ -93,7 +100,7 @@ private void OnActualThemeVariantChanged(object? sender, EventArgs eventArgs) private void RepaintChart() { ApplyChartPalette(); - PlotControl.Refresh(); + RequestFrame(); } // docs/architecture/ui-theme.md#the-plot @@ -121,9 +128,17 @@ private void ApplyChartPalette() // docs/architecture/charting.md#module-layout-avalonia-views--view-models--core-models private static EventHandler ReportDataAreaWidthTo(TrendChartViewModel renderedViewModel) { + float? lastPostedWidth = null; + return (_, renderDetails) => { var dataAreaWidth = renderDetails.DataRect.Width; + if (dataAreaWidth == lastPostedWidth) + { + return; + } + + lastPostedWidth = dataAreaWidth; Dispatcher.UIThread.Post(() => renderedViewModel.ReportDataAreaWidth(dataAreaWidth)); }; } @@ -131,6 +146,7 @@ private static EventHandler ReportDataAreaWidthTo(TrendChartViewM private void OnDataContextChanged(object? sender, EventArgs eventArgs) { _disposables.Clear(); + ClearPendingFrame(); _paintedAxisCount = -1; _viewModel = DataContext as TrendChartViewModel; AxisScale.DataContext = _viewModel?.AxisScale; @@ -183,12 +199,46 @@ private void OnDataContextChanged(object? sender, EventArgs eventArgs) _disposables.Add(_viewModel.RedrawRequested .Subscribe( - _ => - { - PlotControl.Refresh(); - RepositionCursorOverlay(); - }, - ReportFailureOf(nameof(TrendChartViewModel.RedrawRequested)))); + _ => RequestFrame(), + ReportFailureOf(nameof(TrendChartViewModel.RedrawRequested)), + ClearPendingFrame)); + } + + // docs/architecture/charting.md#the-frame-paced-redraw + private void RequestFrame() + { + if (_pendingFrame is not null || TopLevel.GetTopLevel(this) is not { } topLevel) + { + return; + } + + var frame = ++_frameSequence; + topLevel.RequestAnimationFrame(_ => ServeFrame(frame)); + _pendingFrame = frame; + } + + private void ServeFrame(int frame) + { + if (_pendingFrame != frame) + { + return; + } + + _pendingFrame = null; + + if (_viewModel is null || TopLevel.GetTopLevel(this) is null) + { + return; + } + + PlotControl.InvalidateVisual(); + RepositionCursorOverlay(); + ServedFrameCount++; + } + + private void ClearPendingFrame() + { + _pendingFrame = null; } // ChartPalette.Resolve throws on a dropped key. A pipeline that ends stops repainting the chart, so the @@ -247,7 +297,7 @@ private void OnPointerPressed(object? sender, PointerPressedEventArgs eventArgs) case ChartPressAction.PlaceDeltaCursor: _viewModel.PlaceDeltaCursor(AnchorAt(position)); UpdateDeltaCursorLines(); - PlotControl.Refresh(); + RequestFrame(); break; @@ -281,7 +331,7 @@ private void BeginPan(PointerPressedEventArgs eventArgs) eventArgs.Pointer.Capture(PlotControl); HideCursorOverlay(); - PlotControl.Refresh(); + RequestFrame(); } private void OnPointerMoved(object? sender, PointerEventArgs eventArgs) diff --git a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs index 88f6bd3..f0d9785 100644 --- a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs +++ b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs @@ -26,7 +26,6 @@ public sealed class TrendChartViewModel : ReactiveObject, IDisposable private readonly TrendCoordinator _coordinator; private readonly MessagePanelViewModel _messagePanel; private readonly ILogger _logger; - private readonly ChartRedrawSchedule _redrawSchedule; private readonly ChartAxisBinder _axisBinder; private readonly ChartPenSet _penSet; private readonly ChartCursorReader _cursorReader; @@ -37,6 +36,7 @@ public sealed class TrendChartViewModel : ReactiveObject, IDisposable private readonly Dictionary _envelopesById = []; private readonly PenScaleModel _scaleModel = new(); private readonly Dictionary _scalesByPenId = []; + private readonly Subject _redrawRequests = new(); private readonly Subject _historyApplied = new(); private bool _isDisposed; private bool _isHistoryStarted; @@ -57,7 +57,6 @@ public TrendChartViewModel( _coordinator = coordinator; _messagePanel = messagePanel; _logger = logger; - _redrawSchedule = new ChartRedrawSchedule(uiScheduler); _axisBinder = new ChartAxisBinder(Plot); _penSet = new ChartPenSet(Plot, _axisBinder); _cursorReader = new ChartCursorReader(_penSet.ById, _envelopesById); @@ -111,7 +110,8 @@ private set public IReadOnlyDictionary ScaleSettings => _penSet.ScaleSettings; - public IObservable RedrawRequested => _redrawSchedule.Redraws; + /// One signal per redraw request, raised at once on the caller's thread; completes on dispose. + public IObservable RedrawRequested => _redrawRequests.AsObservable(); /// /// One pulse per history result applied, on the UI scheduler. @@ -183,7 +183,7 @@ public void Dispose() _historyDebouncer.Dispose(); AxisScale.Dispose(); _disposables.Dispose(); - _redrawSchedule.Dispose(); + _redrawRequests.OnCompleted(); _historyApplied.Dispose(); Plot.Dispose(); } @@ -695,6 +695,6 @@ public void ReportFailure(Exception failure) private void RequestRedraw() { - _redrawSchedule.Request(); + _redrawRequests.OnNext(Unit.Default); } } diff --git a/docs/architecture/bench.md b/docs/architecture/bench.md index fbc2eac..c81e8d1 100644 --- a/docs/architecture/bench.md +++ b/docs/architecture/bench.md @@ -360,12 +360,9 @@ own size — the headless platform draws nothing. Capture loss is driven through `IPointer.Capture(null)`, the path `Pointer.PlatformCaptureLost` routes into; a version that reroutes `PlatformCaptureLost` leaves the guard green. -**A test that builds `TrendChartView` needs a UI scheduler that can defer.** `TrendChartViewModel` -schedules each redraw once, 33 ms ahead on its UI scheduler. `ImmediateScheduler.Instance` runs that -schedule inline after sleeping 33 ms on the calling thread, so every redraw blocks the dispatcher and -paints inside the call that asked for it. Pass `AvaloniaScheduler.Instance` or a `TestScheduler`, and -dispose every view model built on the Avalonia scheduler inside the test body, since a scheduled -redraw is a 33 ms dispatcher timer that only the dispose cancels. +**A test that builds `TrendChartView` passes `AvaloniaScheduler.Instance` or a `TestScheduler` as the +UI scheduler.** Over `ImmediateScheduler.Instance` a history result can land off the UI thread, and the +view's animation-frame request throws there (`testing-strategy.md#the-ui-scheduler-in-a-realised-view`). **What the guards do not cover.** The Win32 backend and the desktop `AppBuilder` chain are exercised by nothing headless; a desktop-only registration the chain is missing fails at `AppBuilder.Setup` in diff --git a/docs/architecture/charting.md b/docs/architecture/charting.md index 72a035e..93534ed 100644 --- a/docs/architecture/charting.md +++ b/docs/architecture/charting.md @@ -115,16 +115,92 @@ would move it across the plot as the active pen changes. Non-active axes are `IsVisible = false`, and the active-pen switch toggles visibility without rebuilding. Scaling is driven per-axis via `SetLimitsY(min, max, axis)` from the Core `PenScaleModel` output (no global `AutoScale`). Every plottable is pinned to `plot.Axes.Bottom` explicitly at creation, so all pens -share one X axis and per-pen axes are Y-only. Redraws are coalesced to at most 30 FPS by the -`RedrawRequested` seam driving `AvaPlot.Refresh()`, which `Chart/ChartRedrawSchedule` owns for the view -model: a redraw request schedules one emission 33 ms -ahead on the UI scheduler unless one is already scheduled, and the requests inside that span join it. -An idle chart, and a chart no view subscribes to, schedules no redraw; `Dispose` cancels a scheduled -emission. Only data/window/visibility/gesture/ -delta-toggle changes drive ScottPlot redraws (through that coalescing seam); hover and pointer-exit do -**not** call `AvaPlot.Refresh()`. A pointer-move updates only the cheap Avalonia cursor overlay, which -is repositioned from the `RedrawRequested` seam (after `Refresh()`) and on `SizeChanged`. Keeping the -re-rasterization off the per-pointer-event path is the point of the overlay. +share one X axis and per-pen axes are Y-only. + +### Ticks for the drawn axis only + +ScottPlot 5.1.59 regenerates every Y axis twice per frame, at the figure height in +`Layouts.Automatic.GetLayout` and at the data-area height in `RenderActions.RegenerateTicks`, and neither +reads `IsVisible`; only `YAxisBase.Measure` and `Render` skip a hidden axis. So every pen axis carries an +`IDrawnTickGenerator`, a `LinearTickGenerator` or a `LogTickGenerator`, whose `Regenerate` returns at once +while `IsDrawn` is false. `LinearTickGenerator` wraps ScottPlot's `NumericAutomatic` and forwards +`MaxTickCount` to it, which nothing in the application writes. Both keep their last two results in a +`TickCache`: the linear one keyed on the limits, the length and the label font size, the log one on the +limits, the length and the mask. The second length of a frame and every frame of a still axis reuse the +published array. A drawn Auto axis still generates on every pan step, because `PenScaleModel` moves its +range; a Manual axis hits the cache. + +`ChartAxisBinder` installs a `LinearTickGenerator` on every axis it creates, `Axes.Left` included, and on +every switch back from log, each built with the axis's current `IsVisible` as its `IsDrawn`. It writes +`IsDrawn` beside every `IsVisible` write, `HideAxis` included. ScottPlot reads the two at different points +of one render pass under `Plot.Sync`: `IsDrawn` when it regenerates the ticks, `IsVisible` when it measures +and draws the panel. So the binder changes both inside one `lock (Plot.Sync)`, and no frame measures or +draws an axis whose generator skipped its ticks. It takes that lock only when a flag changes, on an +active-pen switch or a pen shown or hidden. The `Apply` of a pan step finds both flags already at their +values, returns before the lock and never waits on a render in progress. A hidden log axis keeps its +`LogTickGenerator`, because the generator type is the axis's log flag (`#log10-y-axis`). The bottom +`DateTimeAutomatic` axis and ScottPlot's default right axis keep their stock generators. + +A hidden generator publishes no ticks. The horizontal gridlines come from `Grid.YAxis`, so the plot draws +none while no pen axis is drawn, as with the active pen switched off. `Grid.YAxis` stays on the last drawn +axis until the binder moves it to the newly drawn one, so a frame that lands between hiding the old active +axis and that move has no gridlines; it is the same frame that has no Y axis. + +### The frame-paced redraw + +The chart redraws at most once per display frame. `TrendChartViewModel.RequestRedraw` raises +`RedrawRequested` at once, on the calling thread, when a view subscribes, and does nothing otherwise; +`Dispose` completes the signal. The view coalesces: when no frame is pending it calls +`TopLevel.RequestAnimationFrame`, and it marks a frame pending only after that call returned, so a view +with no top level requests nothing and is free to request once attached. Avalonia runs the callback on +the UI thread inside the next render pulse (`MediaContext` -> `Clock.Pulse`, a `Render` dispatcher +operation), in the same job that records the visuals and commits the batch, so the plot's +`InvalidateVisual()` lands in that frame. Every request until then joins the pending frame. + +The callback clears the pending mark. Only when the view still has a view model and a top level does it +invalidate the plot, reposition the cursor overlay and count the frame in `ServedFrameCount`, which the +tests read. `OnDataContextChanged`, `OnUnloaded` and the signal's completion clear the pending mark, so a +callback that outlives its view, its view model or its plot does nothing. `OnLoaded`, the theme repaint, +delta-cursor placement and the start of a pan request a frame through the same path; nothing in the view +calls `AvaPlot.Refresh()`. + +`RequestAnimationFrame` verifies the UI thread, so `RequestRedraw` with a view subscribed is UI-thread +only, and Avalonia enforces it; every caller runs on the UI scheduler. A request off the UI thread throws +to its caller, and Rx then ends the view's subscription to the signal: the chart repaints afterwards only +on the view's own requests, which the pending mark, set only after a request succeeded, still serves. +Under Win32 the pulse follows the compositor's commits, one per display frame, so the redraw follows the +display with no timer and no timer resolution in between. + +Only data, window, visibility, gesture and delta-toggle changes request a ScottPlot redraw; hover and +pointer-exit do **not**. A pointer move updates only the Avalonia cursor overlay, which the frame +callback repositions after the invalidation and `SizeChanged` repositions on a resize. + +### Hover and the plot cache + +The overlay does not keep the plot from re-rendering on its own: the crosshair's and the readout's dirty +rects lie inside the plot, so the compositor replays `AvaPlot`'s draw operation, which is ScottPlot's whole +`RenderOnce`, on every pointer move. Measured 2026-10-08 at 8 pens: about 52 renders per second while +hovering, against about 33 inside a drag, ~2.4 ms each. A drag, a zoom and every follow tick change the plot +itself, so they re-render in any design; only a hover over a still plot is wasted work. + +The plot is therefore cached, on its wrapper: `TrendChartView.axaml` puts `PlotControl` alone inside the +`PlotLayer` decorator and sets `CacheMode` to a `BitmapCache` there, not on `AvaPlot`. The compositor keeps +the plot's raster in that layer and blits it while only the overlay changes; the frame callback's +`InvalidateVisual()` reaches the layer as a child's change and ScottPlot renders it once. Measured +2026-10-09 at 8 pens: about one `RenderOnce` per second while hovering a still plot, from the data the writer +appends, against about 52 without the cache; follow and drag render as often as without it. + +The cache sits on the wrapper because a `BitmapCache` on `AvaPlot` itself freezes the plot on its first +frame: Avalonia 12 (12.0.5 through 12.1.3) adds a self-drawing visual's own dirty rect to the window's +region before it switches to the visual's cache collector (`ServerCompositionVisual.Update.cs`, `PreSubgraph`), +so the cache never sees the plot change and blits its first layer until a resize recreates it (no `RenderOnce` +in 15 s of live follow, 2026-10-09). `ThePlotIsCachedThroughItsOnlyAncestor` pins the layout: the decorator +holds the cache and `PlotControl` alone, `PlotControl` holds none. The headless platform returns a stub layer, +so no headless test sees the freeze; the follow capture in `testing-strategy.md#frame-cost` does, and it reruns +after every Avalonia upgrade. + +The layer costs one chart-sized texture per window, about 8 MB at 1920x1080 and 100% scaling and about 19 MB +at 150%, and its text is grayscale-antialiased, because `EnableClearType` stays false. ### Log10 Y axis @@ -146,7 +222,7 @@ frame and projects through the axis's own `GetPixel`, and the line keeps no flag the axis. `GetAxisLimits` reports no Y limits under a log axis, because its columns are in the pen's units and the axis runs in decades; nothing autoscales a pen's axis through ScottPlot today. The binder switches a type under one `lock (Plot.Sync)`: the generator (a new `LogTickGenerator`, or a new -`NumericAutomatic` on the way back), the limits and the minor gridline width, so no frame draws decades +`LinearTickGenerator` on the way back), the limits and the minor gridline width, so no frame draws decades under linear ticks or the reverse. A pan or an autoscale that keeps the type sets the limits as a linear axis does. @@ -198,9 +274,9 @@ number parse reads, such as parentheses, is kept. A pen spanning decades needs a their gridlines and lose their labels. The binder writes `Mask` from `Pen.Format` on every `Apply` on the UI thread, and `Regenerate` reads it on the render thread under `Plot.Sync`, so a revised mask reaches the labels at the next catalogue read. ScottPlot regenerates each Y panel at two lengths per frame, so -the generator caches the last two results, keyed on the limits, the length and the mask; a published -`Ticks` array is never changed. `MaxTickCount` is unused, because `LogAxis.Ticks` bounds the count -itself. +the generator caches the last two results, keyed on the limits, the length and the mask +(`#ticks-for-the-drawn-axis-only`); a published `Ticks` array is never changed. `MaxTickCount` is +unused, because `LogAxis.Ticks` bounds the count itself. **Minor gridlines show on a log axis only.** The binder sets `Grid.YAxisStyle.MinorLineStyle.Width` to 1 when it draws a log axis and to 0 otherwise, in the same step that assigns `Grid.YAxis`, and @@ -283,9 +359,15 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: the sidebar row binds through it. - `Chart/ChartAxisBinder` — applies the `PenScaleModel` output to ScottPlot Y axes (one left axis per pen, `SetLimitsY`, shared-X pinning) and switches an axis between linear and log10: its limits in - decades, its tick generator and the minor gridline width (`#log10-y-axis`). -- `Chart/LogTickGenerator` - the `ITickGenerator` of a log axis: `LogAxis.Ticks` with majors labelled + decades, its tick generator and the minor gridline width (`#log10-y-axis`); writes each generator's + `IsDrawn` beside the axis's `IsVisible` (`#ticks-for-the-drawn-axis-only`). +- `Chart/IDrawnTickGenerator` - the `ITickGenerator` of a pen axis, which generates nothing while its + axis is not drawn (`#ticks-for-the-drawn-axis-only`). +- `Chart/LinearTickGenerator` - the `IDrawnTickGenerator` of a linear axis: ScottPlot's `NumericAutomatic` + behind the two-entry cache (`#ticks-for-the-drawn-axis-only`). +- `Chart/LogTickGenerator` - the `IDrawnTickGenerator` of a log axis: `LogAxis.Ticks` with majors labelled under the pen's mask, and the two-entry cache (`#log10-y-axis`). +- `Chart/TickCache` - the two-entry cache both generators keep their last results in. - `Chart/ChartNavigationController` — owns the `TrendNavigationModel`, the layer ladder, the live-edge advance; raises `WindowChanged` (`NavigationWindow` = `[From, To]` + `Layer` + `RequiresHistoryRequery`). A ceiling is derived, not constant: @@ -295,11 +377,12 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: writer:** `TrendChartViewModel.ReportDataAreaWidth`, called from `TrendChartView`'s `Plot.RenderManager.RenderFinished` handler — do not call `SetTargetColumnCount` from anywhere else, in the same style as the navigation bar's `IsSticky`. That seam carries the `DataRect` of the frame just - rasterised; `Plot.LastRender` read after `Refresh()` would still describe the previous frame, so a + rasterised; `Plot.LastRender` read when a frame is requested would still describe the previous frame, so a resize could leave the layer computed for the old canvas with nothing scheduled to correct it. - `RenderFinished` fires on Avalonia's render thread, so the handler posts every frame's width to the - UI thread and keeps no state of its own. A repeated width changes nothing: `SetTargetColumnCount` drops - an unchanged quantised count. + `RenderFinished` fires on Avalonia's render thread, so the handler posts the width to the UI thread, + and only when it differs from the last width it posted. The last width lives in the handler's closure + and is read and written on the render thread only. A repeated width would change nothing anyway: + `SetTargetColumnCount` drops an unchanged quantised count. A changed *quantised* count re-queries the window even when the layer survives, because it also invalidates the decimation width the visible data was fetched at. The re-query keys on the quantised count while the query resolution follows the unquantised width, so a resize inside the deadband @@ -333,9 +416,6 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: - `Chart/ChartRealtimeApplier` — the append-vs-fold rule per layer for incoming `RealtimeBatch`es. It walks each `PenRealtimeValues` on that pen's own timestamps, never on the batch's union, and hands the union's last timestamp to `ChartNavigationController.OnLiveEdge`. -- `Chart/ChartRedrawSchedule` — the redraw coalescer behind `RedrawRequested`: the subject, the UI - scheduler, the scheduled emission and its flag. `Request()` schedules nothing while an emission is - pending or nothing observes; `Dispose()` cancels the pending one. - `Chart/ChartCursorReader` / `ChartDeltaCursorReader` — view-side state wrapping the Core cursor models, resolving the visible / active pens (`ChartDeltaCursorReader.FormatReadout` formats Δt/Δy). - `Chart/ChartHoverReadout` — pure static `BuildContent`: builds the readout string (local timestamp + diff --git a/docs/architecture/data-integration.md b/docs/architecture/data-integration.md index d2192cc..dd86cba 100644 --- a/docs/architecture/data-integration.md +++ b/docs/architecture/data-integration.md @@ -263,7 +263,14 @@ the pens shown. A gesture that never goes quiet still fetches. `ChartHistoryRequestDebouncer` merges the trailing `Throttle` of 150 ms with a `Sample` of 400 ms over the same requests, so a continuous drag issues one -query per 400 ms and one more after it stops. +query per 400 ms and one more after it stops. `Throttle`, `Sample` and `Merge` hand an emission on +while they hold their own locks, and `Request` takes the `Sample` lock on the UI thread, so the merged +stream passes through `ObserveOn(dataScheduler)` and the debouncer admits and starts each query on the +data scheduler after those locks are released, where a provider that blocks before its first `await` +holds up no `Request`. The production data scheduler is `DefaultScheduler.Instance`, which Rx serves as a +long-running scheduler, so `ObserveOn` drains on one dedicated thread per debouncer, one per chart window: +the thread starts with the first admission, waits blocked between admissions and ends at `Dispose`. That +thread, never a pool thread, carries each admission and the synchronous part of each query start. The chart alone knows what is drawn. It asks for a window only while its fetched range does not cover the window in view, and otherwise pushes `RequestNothing`. The debouncer keeps no drawn window of its diff --git a/docs/architecture/testing-strategy.md b/docs/architecture/testing-strategy.md index a9b7ace..bdc44c7 100644 --- a/docs/architecture/testing-strategy.md +++ b/docs/architecture/testing-strategy.md @@ -170,43 +170,112 @@ and the assertion sits on rows: integration. ## Frame cost Per-frame cost is measured rather than asserted: nothing in the suite can time a real drag. -`scripts/perf/trace-shares.py` reads a dotnet-trace Speedscope export and prints call count, total, -mean and max milliseconds per frame. +`scripts/perf/trace-shares.py` reads a dotnet-trace Speedscope export and prints the chart's frame +cost and cadence. ```powershell dotnet-trace collect -p (Get-Process SemiPlot.UI).Id --format Speedscope --duration 00:00:45 -o drag.speedscope.json -python scripts/perf/trace-shares.py drag.speedscope.speedscope.json +python scripts/perf/trace-shares.py drag.speedscope.speedscope.json --skip-seconds 10 ``` +`--skip-seconds` drops every run that starts within that many seconds of the export's start, so a +capture that began before the warm-up ended is read past it. The export is sampled, about one stack +every 1-2 ms, and a run is one span of consecutive samples: a call shorter than the interval shows +only when a sample lands in it. Run counts of short frames are lower bounds; their milliseconds are +the figure to compare. `NumericAutomatic.GenerateTicks` showed 0.2 runs per `RenderOnce` on the +2026-10-08 drag, where ScottPlot called it about 18 times per frame. + +The script prints, past the skipped head: + +| Output | Read from | +| --- | --- | +| runs, total, mean and max ms of six frames | every thread | +| `RenderOnce` start-to-start spacing p50, p90, max: all frames, and the drag phase, the gaps that hold the start of a `TrendChartView.OnPointerMoved` run | render thread, UI thread | +| compositor frame interval: `ServerCompositor.RenderCore` start spacing, and the rate at its median | render thread | +| pan step to next `RenderOnce`: from the start of a `TrendChartView.OnPointerMoved` run that holds `TrendChartViewModel.OnNavigationWindowChanged` to the next `RenderOnce` start | UI thread, render thread | +| `NumericAutomatic.GenerateTicks` runs and ms per `RenderOnce` | render thread | +| `LabelStyle.Measure` ms per `RenderOnce`, by calling frame | render thread | +| `Monitor.Enter_Slowpath` runs under `TrendChartViewModel.OnNavigationWindowChanged`, count and max | UI thread | + +The UI thread is the profile whose stacks hold `Win32DispatcherImpl.RunLoop`, the render thread the one +holding `WinUiCompositorConnection.RunLoop`; the script stops with a message when either is missing. +Every percentile carries its sample count, because a pan step is seen only when a sample lands in it: +19 in 10 s of the 2026-10-08 drag. + +Two captures check the chart on the demo stand, Release: + +- drag for 45 s: the drag-phase `RenderOnce` spacing p50 sits within 2 ms of the compositor frame + interval, the pan step to the next `RenderOnce` p50 is under 12 ms, `NumericAutomatic.GenerateTicks` + takes 0.12 ms or less per `RenderOnce` (below), and no `Monitor.Enter_Slowpath` run sits under + `TrendChartViewModel.OnNavigationWindowChanged`; +- follow for 20 s with the demo writer running, no pointer in the window and no resize: `RenderOnce` runs + at least once per writer tick, less a small margin. A plot that stopped redrawing passes every other + check, the hover one included, so this capture is the lower bound that catches it; +- hover without a button held over a still plot, follow off, for 20 s: `RenderOnce` runs about once per + writer tick, not once per pointer move (about 52 per second without the plot cache at 8 pens). The count + holds only when the follow capture passes as well (`charting.md#hover-and-the-plot-cache`). + `OnNavigationWindowChanged` is the frame that carries `ApplyAxisModel`, whose cost now scales with the pen count rather than the group count: one `PenScale` and one `SetLimitsY` per pen per window change, 50 on the bench catalogue against 5 before the axis became the pen. -A hidden axis costs nothing to render. Measured on 2026-09-17 against ScottPlot 5.1.59, a 800x600 -plot over 30 frames at 0, 7, 49, 99 and 199 hidden axes spent 17.6, 14.3, 14.2, 14.7 and 18.4 ms per -frame: the count of hidden axes does not move the frame, and one axis per pen does not regress the -drag responsiveness `013f491` landed. Nothing is to be restructured for that cost without a capture -that contradicts these numbers. +A hidden axis used to cost its ticks. Measured on 2026-10-08 on `44bba75`, Release, 8 pens, Raw layer, +one Y axis drawn: ScottPlot ran `NumericAutomatic.GenerateTicks` about 18 times per frame, the eight pen +axes and the default right axis twice each, at 0.351 ms per `RenderOnce`, and `LabelStyle.Measure` took +0.720 ms per `RenderOnce`, 0.281 ms of it under `GenerateTicks`. ScottPlot regenerates every Y axis at +two lengths per frame and reads no `IsVisible` doing so, so a pen axis's generator now skips a hidden axis +and reuses its last two results (`charting.md#ticks-for-the-drawn-axis-only`). The code leaves 4 calls +per drag frame: the drawn Auto pen axis misses its cache at both lengths, because each pan step moves its +range, and the default right axis keeps an uncached `NumericAutomatic`; 4/18 of the before figure is +0.078 ms. The export is sampled, so the check reads the time with a margin for sampling noise: 0.12 ms or +less of `GenerateTicks` per `RenderOnce`, 6/18 of the before figure. One hidden axis that still generated +would add 2 calls per frame; seven would bring back the before figure. The 2026-09-17 measurement that +found hidden axes free was taken on axes whose range was never set. dotnet-trace names the converted file `.speedscope.json`, so the second argument repeats the -extension. Six frames are printed: `RenderOnce`, `Polygon.Render` and `SKCanvas.DrawPath` on the +extension. The six frames of the first table are `RenderOnce`, `Polygon.Render` and `SKCanvas.DrawPath` on the chart's frame path, `OnNavigationWindowChanged`, `ApplyHistory` and `QueryHistoryAsync` on its history path. A 45 s drag capture passes when `RenderOnce` averages 10 ms or less and `SKCanvas.DrawPath` totals 1 s or less. +## Frames in a realised view + +A realised `TrendChartView` redraws on an animation frame (`charting.md#the-frame-paced-redraw`). +`AvaloniaHeadlessPlatform.ForceRenderTimerTick()` ticks the server compositor and completes the +committed batch; the frame callback runs on the `Render` dispatcher operation that follows. A test +drives one frame as `Dispatcher.UIThread.RunJobs(); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); +Dispatcher.UIThread.RunJobs();`, which `ChartViewTestBuilder.DriveOneFrame` holds. The first callback of +an idle view runs on a plain `RunJobs()`. After a frame that invalidated the plot, Avalonia holds the +next pulse until the batch that frame committed is processed, and the tick is what processes it. + +A plain `RunJobs()` also fires the real-time 60 Hz headless render timer once 16 ms have passed, and a +callback that invalidated nothing waits on Avalonia's real-time 16 ms animation timer for its next +pulse, not on the tick. A test therefore counts frames through `TrendChartView.ServedFrameCount`, never +per `RunJobs()` call, and reads its baseline after the view settled: an attached view requests a frame +from `OnLoaded`, which runs after the first pulse. + +`ServedFrameCount` is internal and nothing in production reads it: the one test-only member of the view, +an exception to the rule against them. It counts the frame callbacks that served a request, so the tests +pin coalescing and the pending mark through it, not the repaint. That the served frame invalidates the +plot has no headless observable. The test application uses headless drawing, where `AvaPlot`'s draw +operation gets no Skia lease and renders nothing, so ScottPlot's render count and `RenderFinished` never +move from the realised view; the renderer's dirty set is internal to Avalonia; and a Skia-backed headless +platform would be a second test application, which one test assembly cannot host beside the first. +`Plot.RenderInMemory` does fire `RenderFinished` headless, which the data-area width tests use. + ## The UI scheduler in a realised view A test that realises `TrendChartView` never passes `ImmediateScheduler.Instance` as the chart's UI -scheduler. The view subscribes to `RedrawRequested`, and each redraw request schedules one emission -33 ms ahead. `ImmediateScheduler` runs it inline after sleeping 33 ms on the calling thread, so every -redraw blocks the dispatcher and paints inside the call that asked for it, which production never does. -The schedule is one-shot, so nothing hangs; the hangs belong to `PenCatalogueSync`'s wait loop and the -minimap band's next-read schedule (`AGENTS.md`, Test). Two schedulers work in its place. +scheduler. The history debouncer delivers each result through `ObserveOn(uiScheduler)`, so over +`ImmediateScheduler` the apply runs on whatever thread completed the query, a pool thread once the query +awaits anything. The apply raises `RedrawRequested`, the view requests an animation frame, and +`RequestAnimationFrame` throws off the UI thread. The hangs belong to `PenCatalogueSync`'s wait loop and +the minimap band's next-read schedule (`AGENTS.md`, Test). Two schedulers work in its place. -| Scheduler | When | Cost | -| --- | --- | --- | -| `TestScheduler` | the test drives time itself | none | -| `AvaloniaScheduler.Instance` | the test needs the production seam, as `ChartPointerInputTests` does | a scheduled redraw is a 33 ms timer on the shared headless dispatcher until it fires or the view model is disposed, so the test disposes it | +| Scheduler | When | +| --- | --- | +| `TestScheduler` | the test drives time itself | +| `AvaloniaScheduler.Instance` | the test needs the production seam, as `ChartPointerInputTests` does | The same holds for a window that realises the chart indirectly: `ChartTestBuilder.CreateChart`, which the sidebar tests build on, hands its charts a virtual UI scheduler for this reason alone. Its diff --git a/docs/architecture/trend-feature-spec.md b/docs/architecture/trend-feature-spec.md index 9cb73fa..8cf126c 100644 --- a/docs/architecture/trend-feature-spec.md +++ b/docs/architecture/trend-feature-spec.md @@ -217,8 +217,8 @@ Acceptance-criterion labels: "UI" — verified in the running application; "Core **Acceptance.** Core/test: feeding a point on a coarse layer widens the Min/Max of the last column without adding a new raw point. ### RT-4 — Update/render parameters (NICE) -**Definition.** A data-update interval, pixel-aware thinning, a redraw FPS cap (overlay reposition from a coalesced RedrawRequested ~30 FPS). -**Acceptance.** UI: redraw is no more frequent than the given FPS; pointer-move does not initiate a full plot redraw. +**Definition.** A data-update interval, pixel-aware thinning, a redraw cap of one redraw per display frame (redraw requests join the window's next animation frame, which also repositions the overlay). +**Acceptance.** UI: the plot redraws at most once per display frame; pointer-move does not initiate a full plot redraw. --- diff --git a/docs/architecture/trend-interaction.md b/docs/architecture/trend-interaction.md index e4dd0d4..d9c0f1c 100644 --- a/docs/architecture/trend-interaction.md +++ b/docs/architecture/trend-interaction.md @@ -89,6 +89,8 @@ operator interaction. - **Line style:** both stepped and interpolated, **configurable per pen**. - **Performance:** **FPS locked at 30**; data updates no faster than **10 Hz (100 ms)**; up to **50 displayed pens**. + *As-built note:* the chart redraws at most once per display frame (§RT-4, + charting.md#the-frame-paced-redraw). - **Decimation backend:** kept behind the data-provider **stub** for now; production backend will be **PostgreSQL**, but whether it stores pre-trimmed/layered data is **unknown** — the data layer must support either server-side aggregation or in-process decimation. @@ -128,8 +130,8 @@ as-built mechanics that realize them: single chokepoint (`Chart/ChartHistoryRequestDebouncer`): `Throttle` collapses rapid notches to one trailing request after the gesture goes quiet, the query runs on the data scheduler, one at a time, and the newest window that arrived while it ran runs when it lands (so a read slower than the cap still - completes, and the newest window is the last applied). Per-zoom redraws are coalesced through the - redraw seam, one emission 33 ms after the first request of a span, not an inline refresh. The startup + completes, and the newest window is the last applied). Per-zoom redraws go through the redraw seam + and land on the next display frame with every request made before it, not an inline refresh. The startup `RequestInitialHistory` is an ordinary request on that path, so the initial load and gestures share one latest-wins history path; the first-snap `TrackDataExtents` path stays non-requerying (single initial load). @@ -196,7 +198,7 @@ so there is one left-button gesture, not overlapping hidden branches. `Chart/ChartHoverReadout.BuildContent` string: the local timestamp plus every *visible* pen's value at the cursor X (one line per pen; gap or missing pen → dash). The overlay is suppressed while a drag is in progress or delta mode is active (`IsDragging || IsDeltaModeEnabled`) and is repositioned from - the coalesced `RedrawRequested` seam (after `Refresh()`) and on `SizeChanged` so it tracks + the chart's frame callback (after the plot's invalidation) and on `SizeChanged` so it tracks pan/zoom/resize/live-edge without per-event re-renders. - **Delta cursors (Δt / Δy) via an explicit navigation-bar mode.** The bar's "Delta" toggle (`NavigationBarViewModel.IsDeltaModeEnabled`) sets the chart into `DeltaPlacement`: two left clicks @@ -320,7 +322,8 @@ and the render budget (§RT-4). The as-built rationale and mechanics: (`BucketedRawWindow`, `GROUP BY date_bin`), one row per column, so no raw stream reaches the client. The coarse layers fold in-process through `MinMaxDecimator`, over rows the SCADA already thinned. Both sit behind `IDataProvider` and reach the chart in one envelope vocabulary. -- **Performance budget (§RT-4):** 30 FPS pan/zoom lock; input data ≤ 10 Hz; ≤ 50 simultaneous pens; +- **Performance budget (§RT-4):** at most one redraw per display frame on pan/zoom; input data ≤ 10 Hz; + ≤ 50 simultaneous pens; points per pen handed to the chart ≈ viewport width × 2–4. ## Data quality & line rendering diff --git a/docs/architecture/ui-theme.md b/docs/architecture/ui-theme.md index 6849d36..eb5202b 100644 --- a/docs/architecture/ui-theme.md +++ b/docs/architecture/ui-theme.md @@ -381,12 +381,12 @@ model is bound, and a view with no view model has no chart view model, where an would be a white rectangle inside a dark window. `Chart/TrendChartView.axaml.cs` calls it from three places, and each is a different reason. `OnLoaded` -paints the control's plot and refreshes it; `ActualThemeVariantChanged` repaints and refreshes it; the -`ScalesRevision` subscription paints an axis the scale model created after bind time. That third path +paints the control's plot and requests a frame; `ActualThemeVariantChanged` repaints and requests a frame; +the `ScalesRevision` subscription paints an axis the scale model created after bind time. That third path runs once per pointer move during a pan, so it compares `TrendChartViewModel.AxisCount` with the count -of the last paint and does nothing when the axis set did not grow, and it never calls -`PlotControl.Refresh()` because every site that bumps `ScalesRevision` already follows with -`RequestRedraw()`, which the view samples at the frame budget. +of the last paint and does nothing when the axis set did not grow, and it never requests a frame itself +because every site that bumps `ScalesRevision` already follows with +`RequestRedraw()`, which the view joins into the next display frame. The minor gridlines take their colour on every axis but draw only on a log10 axis: `ChartAxisBinder` sets their width to 1 px when the drawn axis is logarithmic and to 0 otherwise (`charting.md#log10-y-axis`). diff --git a/docs/plans/completed/20261008-chart-render-headroom.md b/docs/plans/completed/20261008-chart-render-headroom.md new file mode 100644 index 0000000..0210948 --- /dev/null +++ b/docs/plans/completed/20261008-chart-render-headroom.md @@ -0,0 +1,395 @@ +# Chart render headroom + +## Overview + +A drag on the demo stand repaints the chart at about 30 frames per second and late, the hover crosshair +re-rasterizes the whole plot on every pointer move, and every hidden pen axis regenerates its ticks twice per +frame. No thread is saturated, so the operator feels latency and cadence rather than load, and the costs that +scale with the pen count leave little headroom for the 50-pen catalogue. This plan removes the self-imposed +delays and the wasted work: + +- the chart redraws once per display frame, paced by the window's animation frame instead of a 33 ms timer; +- the plot visual is bitmap-cached by the compositor, so the overlay above it repaints without re-rendering + the plot; +- a hidden Y axis generates no ticks, and a drawn axis reuses its ticks while its range and length stand; +- a paced history request leaves the debouncer's sample gate before the query starts; +- the chart reports its data-area width only when it changes. + +Measured 2026-10-08 on `44bba75`, Release, 8 pens, Raw layer, 20 s trace (the first ~10 s warm-up): + +- `RenderOnce` after warm-up: 338 frames, mean 2.39 ms, p90 3.97 ms, max 7.4 ms; +- inside a drag: plot frame spacing p50 33.9 ms, p90 51.1 ms, max 82.7 ms; pan step to the next `RenderOnce` + start p50 20.3 ms, p90 42.3 ms, max 50.7 ms; the compositor itself runs 60.8 frames per second; +- while hovering: 52 plot renders per second (278 in 5.3 s) against 33 inside a drag; +- about 18 `NumericAutomatic.GenerateTicks` runs per frame (eight pen axes and the default right axis, twice + each) with one Y axis drawn; `GenerateTicks` 0.35 ms and `LabelStyle.Measure` 0.72 ms per frame; +- one UI-thread stall of 10.15 ms: `OnNavigationWindowChanged` -> `Sample.OnNext` -> `Monitor.Enter_Slowpath`. + +## Context (from discovery) + +- `ChartRedrawSchedule` (`SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs`, 48 lines) schedules one emission + 33 ms ahead (`_redrawDelay`, `:12`) when none is scheduled and a view subscribes (`:26-35`); `Dispose` cancels + it. `TrendChartViewModel` owns it (`SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs:29`, `:60`, `:114`). +- The production UI scheduler is `AvaloniaScheduler` (ReactiveUI.Avalonia 12.0.3): a zero-delay schedule runs + inline on the UI thread, a non-zero one goes through `DispatcherTimer.RunOnce`, which `Avalonia.Win32` 12.0.5 + backs with `SetTimer` on the system clock tick (15.625 ms unless raised); its `Now` is the wall clock + (`LocalScheduler`). These facts come from the decompiled packages in the local NuGet cache. +- `TrendChartView` subscribes to `RedrawRequested` and calls `PlotControl.Refresh()` then + `RepositionCursorOverlay()` (`SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml.cs:184-191`); `RepaintChart`, + `PlaceDeltaCursor` and `BeginPan` call `Refresh()` as well. ScottPlot.Avalonia 5.1.59's `AvaPlot.Refresh()` is + `Dispatcher.UIThread.InvokeAsync(InvalidateVisual, DispatcherPriority.Background)` (decompiled). +- The view constructor configures `PlotControl` (`TrendChartView.axaml.cs:45-63`); no test pins these settings + today. `ReportDataAreaWidthTo` posts the data-area width to the UI thread on every `RenderFinished` + (`TrendChartView.axaml.cs:122-129`). +- ScottPlot 5.1.59 regenerates every Y axis twice per frame at two lengths: `Layouts.Automatic.GetLayout` calls + `RegenerateTicks(figureRect.Height)` for every panel, then `RenderActions.RegenerateTicks` calls + `RegenerateTicks(rp.DataRect.Height)` for every plottable axis; neither checks `IsVisible`, while + `YAxisBase.Measure` and `Render` return early for a hidden axis (decompiled). +- `ChartAxisBinder.Apply` (`SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs:32-75`) sets limits on every pen's + axis on every window change, swaps the tick generator between `LogTickGenerator` and `NumericAutomatic` + under `Plot.Sync` (`:56-65`), and sets `axis.IsVisible` for the active visible pen only (`:68`); `HideAxis` + hides a removed pen's axis (`:24-30`). A new axis comes from `Axes.Left` or `AddLeftAxis()` (`CreateAxis`). +- An axis's log type lives in its generator: `LogTickGenerator.IsLogarithmic(axis)` + (`SemiPlot/SemiPlot.UI/Chart/LogTickGenerator.cs:50-53`). `LogTickGenerator.Regenerate` keeps two cached + results, `_recent` and `_older`, keyed on range, length and mask (`:16-17`, `:27-47`), and runs on the render + thread under `Plot.Sync`. +- `PenScaleModel.Compute` scales an Auto pen from the window in view, so an Auto axis gets a new range on every + pan step; a Manual axis keeps its range. +- `ChartHistoryRequestDebouncer` merges a `Throttle` head and a `Sample` head into one `Admit` subscriber + (`SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs:52-61`); admitted queries start through + `Observable.FromAsync` (`:63-73`). Rx 6.1 forwards both heads inside their gates. +- Tests pinning the redraw rule: `TrendChartViewModelTests` `_redrawDelay` 33 ms (`:39`), `AnIdleChartSchedulesNoRedraw`, + `AChartNoViewSubscribesToSchedulesNoRedraw`, `DisposingTheChartCancelsTheScheduledRedraw`, + `ABurstOfRequestsRedrawsOncePerSpanAndOnceAfterTheLastRequest` (`:350-413`), the redraw count at `:1959-1970`, + and `ChartPointerInputTests`, which runs the production `AvaloniaScheduler`. +- Docs and specs stating the 33 ms / 30 FPS rule: `AGENTS.md:141`, `docs/architecture/charting.md:118-127`, + `docs/architecture/trend-interaction.md:132` and `:323` (§RT-4 "30 FPS pan/zoom lock"), + `docs/architecture/trend-feature-spec.md:220`, `docs/architecture/testing-strategy.md:199-210`, + `docs/architecture/bench.md:364-368`. `testing-strategy.md:185-189` records that hidden axes cost nothing, a + measurement taken on axes whose range was never set. +- `scripts/perf/trace-shares.py` prints call count, total, mean and max for six frames (`:23`, `:109-124`). +- The before trace is copied into the repository as `SemiPlot/Artifacts/perf/44bba75-drag.speedscope.json` + (ignored by git with the rest of `Artifacts/`); its source is this session's scratchpad + `C:\Users\admin\AppData\Local\Temp\claude\C--Users-admin-projects-SemiPlot\0a3f5e31-9cf1-4552-a073-b35ee9d955e0\scratchpad\perf\master.speedscope.json`. + +## Development Approach + +- **testing approach**: Regular (code first, then tests) +- complete each task fully before moving to the next; every task carries its tests +- every task ends on `dotnet test SemiPlot.slnx` and `dotnet format SemiPlot.slnx --verify-no-changes` +- update this plan when scope changes during implementation + +## Testing Strategy + +- unit tests in `SemiPlot.Tests.Unit`: the view model's redraw signal, the view's frame request through the + headless render timer, the tick generators directly, the debouncer over `HistoryDebouncerTestBuilder` +- the compositor path (bitmap cache, frame cadence) cannot be observed headless; it is measured with + `dotnet-trace` on the demo stand through `trace-shares.py` + +## Acceptance Evidence + +Automatable, each red before its task and green after: + +1. `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj --filter "FullyQualifiedName~TrendChartViewTests.ABurstOfRedrawRequests_JoinsOneFrame"`: + the requests before one frame join it. It pins the coalescing only; that the served frame invalidates the plot + has no headless observable (`testing-strategy.md#frames-in-a-realised-view`). +2. `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj --filter "FullyQualifiedName~TrendChartViewTests.ThePlotIsCachedThroughItsOnlyAncestor"`: + the cache sits on the `PlotLayer` decorator, which holds `PlotControl` alone, and `PlotControl` holds none + (a cache on `AvaPlot` itself froze the plot on the stand, `charting.md#hover-and-the-plot-cache`). +3. `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj --filter "FullyQualifiedName~ChartTickGeneratorTests"`: + a hidden axis generates nothing; a drawn axis regenerated at lengths A, B, A, B over one range generates twice, + not four times. +4. `dotnet test SemiPlot.slnx` green. + +Manual, on the demo stand, Release, `dotnet-trace collect -p --format Speedscope --duration 00:00:00:45` +started after warm-up, then `python scripts/perf/trace-shares.py `; the before numbers come from the same +script over `SemiPlot/Artifacts/perf/44bba75-drag.speedscope.json`: + +1. Drag for 45 s: drag-phase `RenderOnce` spacing p50 within 2 ms of the compositor frame interval the script + reports (16.7 ms at 60 Hz), from 33.4 ms; pan step to next `RenderOnce` p50 under 12 ms, from 24.4 ms. +2. Follow for 20 s with the writer running and no input: `RenderOnce` at least once per writer tick. Hover + without dragging over a still plot, follow off, for 20 s: `RenderOnce` about once per writer tick, from ~52 + per second; it counts only together with the follow check. Measured 2026-10-09: follow 13 sampled runs in + 20 s at a 1.5 s median spacing (0 with the cache on `AvaPlot`), hover 57 runs in 20 s with a 1000 ms median + spacing during pointer moves. +3. Drag: `NumericAutomatic.GenerateTicks` 0.12 ms or less per `RenderOnce`, from 0.351 ms. ScottPlot calls it + about 18 times per frame today (eight pen axes and the default right axis, twice each). The code leaves 4 + calls per drag frame: the drawn Auto pen axis misses its cache at both lengths, because each pan step moves + its range, and the default right axis keeps an uncached `NumericAutomatic`, twice each; 4/18 of the before + figure is 0.078 ms. The export is sampled, so the check reads the time, not a call count, and 0.12 ms + (6/18) leaves a margin for sampling noise; one hidden axis still generating would add 2 calls per frame. +4. No `Monitor.Enter_Slowpath` under `TrendChartViewModel.OnNavigationWindowChanged`. + +Before, `python scripts/perf/trace-shares.py SemiPlot/Artifacts/perf/44bba75-drag.speedscope.json --skip-seconds 10`: + +| Output | p50 | p90 | max | n | +| --- | --- | --- | --- | --- | +| `RenderOnce` spacing, all, ms | 18.2 | 50.5 | 168.7 | 336 | +| `RenderOnce` spacing, drag phase, ms | 33.4 | 50.6 | 168.7 | 89 | +| compositor frame interval, ms | 16.8 | 32.8 | 116.4 | 505 | +| pan step to next `RenderOnce`, ms | 24.4 | 39.4 | 45.1 | 19 | + +- `RenderOnce`: 337 runs, mean 2.4 ms, max 7.4 ms; compositor 59.4 frames/s at the median interval. +- `NumericAutomatic.GenerateTicks`: 66 runs, 0.351 ms per `RenderOnce`; `LabelStyle.Measure` 0.720 ms per + `RenderOnce`, 0.281 ms of it under `NumericAutomatic.GenerateTicks`. +- `Monitor.Enter_Slowpath` under `TrendChartViewModel.OnNavigationWindowChanged`: 9 runs, max 2.25 ms; the + 10.15 ms stall falls in the skipped first 10 s (19 runs over the whole export). + +## Solution Overview + +**Frame-paced redraw.** The view model's `RedrawRequested` becomes a plain signal: `RequestRedraw` raises it at +once when a view subscribes and does nothing otherwise, and `Dispose` completes it. `ChartRedrawSchedule` and its +33 ms timer are deleted. The view coalesces: when no frame is pending, the handler calls +`TopLevel.RequestAnimationFrame` and sets the pending flag only after that call succeeded; a view with no top +level requests nothing and leaves the flag clear. The frame callback clears the flag and, when the view still has +a view model and is attached, calls `PlotControl.InvalidateVisual()` and `RepositionCursorOverlay()` and counts +the frame in an internal counter the tests read. `OnLoaded` requests a frame, so a view attached after requests it +could not serve redraws once; `OnDataContextChanged` and `OnUnloaded` clear the pending state, so a callback that +outlives its view or its plot does nothing. Every other `Refresh()` call in the view (`RepaintChart`, +`PlaceDeltaCursor`, `BeginPan`) goes through the same frame request. + +Avalonia 12.0.5 runs the callback on the UI thread inside the next render pulse (`MediaContext` -> `Clock.Pulse`, +posted at `DispatcherPriority.Render`), in the same dispatcher job that records the visuals and commits the +batch, so the invalidation lands in that frame. Under Win32 the pulse follows the compositor's commits, one per +display frame; a request made inside the callback lands on the next pulse. The redraw then follows the display: +the first request of a burst draws on the next frame, every request until then joins it, and there is no timer, +no timer resolution and no clock arithmetic. `RequestAnimationFrame` verifies the UI thread, so +`RequestRedraw` is UI-thread only, enforced by Avalonia; every caller is on the UI thread today and requests +last in its method. The handler does nothing that can throw beyond requesting a frame, so the synchronous raise +inside `ApplyHistory`, `ApplyCatalogue` or `OnNavigationWindowChanged` re-enters nothing. §RT-4 changes from a +30 FPS lock to one redraw per display frame. At 60 Hz and ~2.4 ms per `RenderOnce` the render thread goes from +~22% to ~30% busy at 8 pens. + +**Bitmap-cached plot.** `PlotControl.CacheMode = new BitmapCache()` in the view constructor. Avalonia 12's +compositor keeps the plot's raster in a layer whose dirty rects are its own, and blits it while only the overlay +(crosshair, readout) is dirty; `InvalidateVisual` dirties the cache and re-renders it once. The layer follows the +window scaling and the plot's pixel size, so resize and DPI changes need no code; the headless platform returns a +stub layer, so headless tests are unaffected. This is the layered design the overlay was written for: without +the cache the overlay's dirty rect lies inside the plot and the compositor replays ScottPlot's draw operation in +full. Cost: one chart-sized texture per window, about 8 MB at 1920x1080 and 100% scaling, about 19 MB at 150%. +The layer's text is grayscale-antialiased (`EnableClearType` false). + +**Ticks for drawn axes only.** A new `LinearTickGenerator` wraps ScottPlot's `NumericAutomatic` and keeps two +cached results keyed on range, length and label font size, as `LogTickGenerator` does, because ScottPlot asks for +each axis at two lengths per frame. Both generators carry `IsDrawn`; a generator whose axis is not drawn returns +without generating and leaves `Ticks` empty. The generator type keeps carrying the log flag, so a hidden log pen +still projects through its axis. `ChartAxisBinder` gives every axis it creates or switches to linear a +`LinearTickGenerator` and writes `IsDrawn` beside every `axis.IsVisible` write in an order that never shows an +axis without ticks: `IsDrawn = true` before `IsVisible = true`, `IsVisible = false` before `IsDrawn = false`. +`IsDrawn` is written on the UI thread and read on the render thread; a bool write is atomic. The saving is the +hidden axes; a drawn Auto axis still regenerates on every pan step because its range moves, a Manual one hits. + +**Sample gate.** The merged admission stream of `ChartHistoryRequestDebouncer` passes through +`ObserveOn(dataScheduler)` before `Admit`, so a paced emission leaves Rx's `Sample` gate before the admitted query +starts and a UI-thread `Request` never waits on it. The read stamping is taken in `Request` and is unaffected. + +**Width on change.** `ReportDataAreaWidthTo` posts only when the width differs from the last one it posted. + +**Not in scope.** `Win32PlatformOptions.CompositionMode = LowLatencyDxgiSwapChain`: it needs D3D feature level +11_3, falls through silently when unsupported, and buys about one compositor hop that nothing measured asks for. +Revisit with a PresentMon number and an operator latency complaint. + +## Technical Details + +- `trace-shares.py` gains: `RenderOnce` start-to-start spacing (p50, p90, max); the compositor frame interval + (`ServerCompositor.RenderCore` spacing p50); pan step to next `RenderOnce` (an `OnPointerMoved` run containing + `TrendChartViewModel.OnNavigationWindowChanged`, to the next `RenderOnce` start on the render thread, p50 and + p90); `NumericAutomatic.GenerateTicks` runs and ms per `RenderOnce`; `LabelStyle.Measure` attributed by + parent frame; and whether `Monitor.Enter_Slowpath` occurs under `TrendChartViewModel.OnNavigationWindowChanged`. + A `--skip-seconds` option drops the warm-up. The export is sampled (about one stack per 1-2 ms), so a run + count of a sub-millisecond frame is a lower bound and its milliseconds are the comparable figure. +- `LinearTickGenerator` cache key: `range.Min`, `range.Max`, `size.Length`, `labelStyle.FontSize`; the edge is + fixed per axis. On a miss it calls `NumericAutomatic.Regenerate` and keeps its `Ticks`. +- Headless frames: `AvaloniaHeadlessPlatform.ForceRenderTimerTick()` ticks the server compositor and completes the + committed batch; the animation-frame callback itself runs on the `Render` dispatcher operation that follows, so + a test drives one frame as `Dispatcher.UIThread.RunJobs(); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs();`. The first callback of an idle view runs on a plain `RunJobs()`. A plain + `RunJobs()` also fires the real-time 60 Hz headless render timer when 16 ms have elapsed, so a test counts frames + per pulse through the view's internal frame counter, never per `RunJobs()` call. ScottPlot's `RenderFinished` + never fires headless (the draw operation has no Skia lease), so it is no observable. + +## Implementation Steps + +### Task 1: Measure what the plan changes + +**Files:** +- Modify: `scripts/perf/trace-shares.py` +- Modify: `docs/architecture/testing-strategy.md` + +- [x] the outputs listed in Technical Details +- [x] copy the before trace to `SemiPlot/Artifacts/perf/44bba75-drag.speedscope.json`, run the script on it and + record the before numbers in this plan's Acceptance Evidence +- [x] `testing-strategy.md` "Frame cost": the new outputs and the drag and hover checks +- [x] `dotnet format SemiPlot.slnx --verify-no-changes` + +### Task 2: Pace the redraw by the display frame + +**Files:** +- Delete: `SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs` +- Modify: `SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs` +- Modify: `SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartViewTestBuilder.cs` +- Modify: `AGENTS.md`, `docs/architecture/charting.md`, `trend-interaction.md`, `trend-feature-spec.md`, + `testing-strategy.md`, `bench.md` + +- [x] view model: `RedrawRequested` raised at once with a subscriber, nothing without one, completed on dispose +- [x] view: frame request with the flag set only after success, the guarded callback, the internal frame counter, + a request in `OnLoaded`, pending state cleared in `OnDataContextChanged` and `OnUnloaded`; `RepaintChart`, + `PlaceDeltaCursor`, `BeginPan` through the same request +- [x] test `ABurstOfRedrawRequestsInvalidatesThePlotOncePerFrame`: a burst on a shown view, one pulse, one frame + counted (renamed in review to `ABurstOfRedrawRequests_JoinsOneFrame`, the counter to `ServedFrameCount`: + they pin the coalescing, not the repaint) +- [x] test: a request on a detached view, then attach to a shown window and request again; one frame is counted + (for the later request; the attached view's `OnLoaded` frame runs after the first pulse and is the baseline) +- [x] test: a frame pending when the view model is disposed runs as a no-op +- [x] rewrite the view-model redraw tests to the signal (idle, no subscriber, dispose, counts) and the comments at + `TrendChartViewModelTests.cs:1964-1965` and `:2090-2091`; run `ChartPointerInputTests` and fix what the + change moves +- [x] docs and specs: frame-paced redraw and §RT-4; delete the `Chart/ChartRedrawSchedule` module entry + (`charting.md:336-338`); replace `testing-strategy.md` "The UI scheduler in a realised view" (`:197-215`) and + `AGENTS.md:141-144` with the frame protocol in Technical Details; drop the timer reasoning from + `bench.md:364-368` and the comments of `TrendChartViewTests.CreateViewModel` and + `ChartViewTestBuilder.CreateLoadedViewModel` +- [x] `git grep -n "33 ms\|33ms" -- docs AGENTS.md SemiPlot/SemiPlot.Tests.Unit` prints nothing about the redraw + (matches remain only in `docs/plans/`: this plan and the completed plans, which record history) +- [x] `dotnet test SemiPlot.slnx` and `dotnet format SemiPlot.slnx --verify-no-changes` + +### Task 3: Bitmap-cache the plot visual + +**Files:** +- Modify: `SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs` +- Modify: `docs/architecture/charting.md` + +- [x] `PlotControl.CacheMode = new BitmapCache()` in the constructor, with a pointer to `charting.md` +- [x] test `ThePlotIsBitmapCached` +- [x] `charting.md:119-127`: the cached plot layer is what keeps the overlay cheap; memory per window (written + as the new section `charting.md#the-cached-plot-layer` after the frame-paced redraw) +- [x] `dotnet test SemiPlot.slnx` and `dotnet format SemiPlot.slnx --verify-no-changes` +- [x] withdrawn: on the stand the cache froze the plot (no `RenderOnce` in 15 s of live follow, 2026-10-09); + the line, `ThePlotIsBitmapCached` and the section are removed, and `charting.md#hover-and-the-plot-cache` + records why +- [x] redone on the wrapper: `PlotControl` alone inside the `PlotLayer` decorator, which holds the + `BitmapCache`; test `ThePlotIsCachedThroughItsOnlyAncestor`; the stand follow and hover captures pass + (Acceptance Evidence, manual item 2) + +### Task 4: Generate ticks for drawn axes only + +**Files:** +- Create: `SemiPlot/SemiPlot.UI/Chart/LinearTickGenerator.cs` +- Modify: `SemiPlot/SemiPlot.UI/Chart/LogTickGenerator.cs` +- Modify: `SemiPlot/SemiPlot.UI/Chart/ChartAxisBinder.cs` +- Create: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartTickGeneratorTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs` +- Modify: `docs/architecture/charting.md`, `testing-strategy.md` + +- [x] `LinearTickGenerator` with the two-entry cache and `IsDrawn`; `IsDrawn` on `LogTickGenerator` (both + implement `IDrawnTickGenerator` and share `TickCache`) +- [x] `ChartAxisBinder`: a `LinearTickGenerator` on every created or linear axis; `IsDrawn` beside every + `axis.IsVisible` write in the stated order, `HideAxis` included +- [x] `ChartTickGeneratorTests`: hidden generates nothing; lengths A, B, A, B over one range generate twice; a new + range regenerates; a log generator hidden then drawn ticks again +- [x] `ChartAxisBinderTests`: only the active visible pen's generator is drawn; a hidden log pen's axis stays + logarithmic +- [x] docs: the tick rule in `charting.md` (`#ticks-for-the-drawn-axis-only`); replace the + `testing-strategy.md` "A hidden axis costs nothing to render" paragraph with the 2026-10-08 measurement +- [x] `dotnet test SemiPlot.slnx` and `dotnet format SemiPlot.slnx --verify-no-changes` + +### Task 5: Release the sample gate before a query starts + +**Files:** +- Modify: `SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs` +- Modify: `docs/architecture/data-integration.md` + +- [x] `ObserveOn(dataScheduler)` between the merged heads and `Admit` +- [x] a test that `Request` returns while a paced admission is starting its query; run + `ChartHistoryRequestCancellationTests`, `TrendChartCatalogueTests` and `TrendChartViewModelTests` and add any + test the extra scheduler step moves to this task (moved: an advance ending on a sample tick in + `AContinuousGestureFetchesAtTheCapInterval`, `AGestureEndingOnTheWindowBeingReadLeavesTheReadRunning` and + `AReadForAnOlderPenSetLeavesTheNewPensReadRunning`; the two legend `LoadInitialHistory` helpers, whose UI + scheduler is the same `TestScheduler`, advance `+ 2`) +- [x] `data-integration.md`: one sentence on where admission runs +- [x] `dotnet test SemiPlot.slnx` and `dotnet format SemiPlot.slnx --verify-no-changes` + +### Task 6: Report the data-area width on change + +**Files:** +- Modify: `SemiPlot/SemiPlot.UI/Chart/TrendChartView.axaml.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewTests.cs` + +- [x] `ReportDataAreaWidthTo` posts only a changed width +- [x] test: two renders at one width post once (`TwoRendersAtOneWidth_ReportTheWidthOnce`, through the production + `RenderFinished` by `Plot.RenderInMemory`) +- [x] `dotnet test SemiPlot.slnx` and `dotnet format SemiPlot.slnx --verify-no-changes` + +### Task 7: Verify acceptance criteria + +- [x] the three automatable acceptance tests green, each red with its change reverted (green at `bc6db78`, 10 + of 10; reverted in the working tree only, then restored: `RequestFrame` without the pending guard drew 12 + frames for 3 expected; no `CacheMode` found ``; the `IsDrawn` guard off in both generators failed the + five hidden-axis cases; the linear `TickCache` bypassed failed `TheTwoLengthsOfOneFrame_GenerateTwice`. + Tasks 5 and 6 carry no acceptance test in the list and were not reverted) +- [x] `dotnet test SemiPlot.slnx` (unit 1839 of 1839, integration 147 of 147) +- [x] `dotnet format SemiPlot.slnx --verify-no-changes` + +### Task 8: [Final] Update documentation + +- [x] `AGENTS.md` only if a new rule appeared beyond Task 2's edit (no edit: every chart, scheduler and test + statement still holds against `cd20c20`, and the anchors and helpers it names exist; the later rules, the + `IsDrawn`/`IsVisible` order with `ChartAxisBinder` as the axes' one writer and the UI-thread-only redraw, are + chart internals that `charting.md#ticks-for-the-drawn-axis-only` and `#the-frame-paced-redraw` hold, and the + redraw's one test consequence, the immediate UI scheduler throwing, is already in Task 2's edit) +- [x] move this plan to `docs/plans/completed/` (left for delivery) + +## Post-Completion + +**Manual verification**: the four trace checks in Acceptance Evidence, before shipping. If the hover check +fails, the fallback is an owned surface: a sealed `AvaPlot` subclass whose draw operation renders into a +device-pixel `SKSurface` only when the view's frame callback raises a dirty flag, then draws that image; same +render thread, same `Plot.Sync`. One visual check of tick-label edges with the cache on; if they fringe, set +`EnableClearType` and `SnapsToDevicePixels` on the cache. One minimized-window check: minimize the stand for 10 s +with the writer running and read the `RenderOnce` count from a trace; frames either stop and resume on restore or +continue unseen as today, and `charting.md` records which. + +**After an Avalonia upgrade**: rerun the hover trace; `BitmapCache` is new in Avalonia 12 and its invalidation +rules may change. + +**Executed by exec:** +- branch: chart-render-headroom + +## Verify it yourself + +Automated, from the repository root: + +1. `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj --filter "FullyQualifiedName~TrendChartViewTests|FullyQualifiedName~ChartTickGeneratorTests|FullyQualifiedName~ChartAxisBinderTests|FullyQualifiedName~ChartHistoryRequestDebouncerTests"` + passes at HEAD. Each change was checked red with its production edit reverted: no coalescing gives 12 frames + instead of 3 (`ABurstOfRedrawRequests_JoinsOneFrame`), no `IsDrawn` guard fails the hidden-axis cases, a bypassed `TickCache` generates four times at lengths + A, B, A, B, no `ObserveOn` hop times out `RequestReturnsWhileAPacedAdmissionIsStartingItsQuery`, no width check + fails `TwoRendersAtOneWidth_ReportTheWidthOnce`. +2. `dotnet test SemiPlot.slnx` (unit 1843, integration 147) and `dotnet format SemiPlot.slnx --verify-no-changes`. + +On the demo stand, Release (`dotnet run -c Release --project SemiPlot/SemiPlot.AppHost`), with the viewer's process +id from Task Manager: + +1. Wait until the window finishes warming up (about 10 s), start + `dotnet-trace collect -p --format Speedscope --duration 00:00:00:45 -o drag.nettrace` and drag the chart + back and forth for the whole 45 s. +2. `python scripts/perf/trace-shares.py drag.speedscope.json --skip-seconds 0` and compare with the before table + above (`... 44bba75-drag.speedscope.json --skip-seconds 10`): drag-phase `RenderOnce` spacing p50 near the + compositor interval (16.8 ms) instead of 33.4 ms, pan step p50 under 12 ms instead of 24.4 ms, `GenerateTicks` + 0.12 ms or less per `RenderOnce` instead of 0.351 ms, no `Enter_Slowpath` run under `OnNavigationWindowChanged`. +3. Follow for 20 s with the writer running, no pointer in the window, no resize, under a 20 s trace: `RenderOnce` + at least once per writer tick. Watch the bottom time axis advance for 10 s, then switch the theme and confirm + the plot repaints without a resize. +4. Minimize the window for 10 s under a trace to record whether frames stop. + +The behaviour the operator sees: the chart follows a drag every display frame instead of every second one, and +with the active pen switched off the plot shows no horizontal gridlines. Hover over a still plot re-renders it +only when new data arrive. diff --git a/scripts/perf/trace-shares.py b/scripts/perf/trace-shares.py index e8c5c52..03bbf83 100644 --- a/scripts/perf/trace-shares.py +++ b/scripts/perf/trace-shares.py @@ -1,24 +1,30 @@ -"""Per-frame cost of a drag, read from a dotnet-trace Speedscope export. +"""Per-frame cost and cadence of a drag, read from a dotnet-trace Speedscope export. Collect while dragging the chart, then read the export: dotnet-trace collect -p (Get-Process SemiPlot.UI).Id --format Speedscope ` --duration 00:00:45 -o drag.speedscope.json - python scripts/perf/trace-shares.py drag.speedscope.speedscope.json + python scripts/perf/trace-shares.py drag.speedscope.speedscope.json --skip-seconds 10 dotnet-trace writes the converted file next to the requested output and names it `.speedscope.json`, so the argument above is not a typo. -The script prints call count, total, mean and max milliseconds for the frames of the chart's -frame and history paths, summed over every evented profile in the export. Frames are matched by -substring, so `Polygon.Render` matches +Frames are matched by substring, so `Polygon.Render` matches `ScottPlot!ScottPlot.Plottables.Polygon.Render(class ScottPlot.RenderPack)`. A frame that recurses is timed on its outermost call only. + +See docs/architecture/testing-strategy.md#frame-cost. """ +import argparse +import bisect import json +import statistics import sys +from collections import defaultdict +from dataclasses import dataclass, field from pathlib import Path +from typing import Any FRAMES = ( "RenderOnce", @@ -29,6 +35,27 @@ "QueryHistoryAsync", ) +RENDER_ONCE = "RenderOnce" +COMPOSITOR_FRAME = "ServerCompositor.RenderCore" +GENERATE_TICKS = "NumericAutomatic.GenerateTicks" +MEASURE_LABEL = "LabelStyle.Measure" +CHART_POINTER_MOVED = "TrendChartView.OnPointerMoved" +CHART_WINDOW_CHANGED = "TrendChartViewModel.OnNavigationWindowChanged" +LOCK_CONTENTION = "Monitor.Enter_Slowpath" + +TRACKED = ( + *FRAMES, + COMPOSITOR_FRAME, + GENERATE_TICKS, + MEASURE_LABEL, + CHART_POINTER_MOVED, + CHART_WINDOW_CHANGED, + LOCK_CONTENTION, +) + +UI_THREAD_MARKER = "Win32DispatcherImpl.RunLoop" +RENDER_THREAD_MARKER = "WinUiCompositorConnection.RunLoop" + MILLISECONDS_PER_UNIT = { "nanoseconds": 1e-6, "microseconds": 1e-3, @@ -37,31 +64,64 @@ } -def label_of(frame_name: str) -> str | None: - for frame in FRAMES: - if frame in frame_name: - return frame - return None +@dataclass +class Run: + """One outermost span of a tracked frame on one thread.""" + + start: float + end: float + parent: str + enclosing: frozenset[str] + nested: set[str] = field(default_factory=set) + + @property + def duration(self) -> float: + return self.end - self.start + + +@dataclass +class Trace: + """Runs per tracked frame, pooled and per thread, past the skipped head of an export.""" + + runs: dict[str, list[Run]] + ui_runs: dict[str, list[Run]] + render_runs: dict[str, list[Run]] + span: float + + +def method_of(frame_name: str) -> str: + """The qualified method of a frame name, without its module and parameter list.""" + return frame_name.split("!", 1)[-1].split("(", 1)[0] + +def labels_of(frame_name: str) -> tuple[str, ...]: + return tuple(label for label in TRACKED if label in frame_name) -def measure_profile( - profile: dict, labels: list[str | None], scale: float -) -> dict[str, list[float]]: - """Durations per frame of interest, from one evented profile's open and close events.""" - durations: dict[str, list[float]] = {frame: [] for frame in FRAMES} - depth = dict.fromkeys(FRAMES, 0) - opened_at: dict[str, float] = {} - stack: list[str | None] = [] + +def read_profile( + profile: dict[str, Any], + frame_names: list[str], + frame_labels: list[tuple[str, ...]], + scale: float, +) -> dict[str, list[Run]]: + """Outermost runs per tracked frame, from one evented profile's open and close events.""" + runs: dict[str, list[Run]] = defaultdict(list) + open_runs: dict[str, Run] = {} + depth: dict[str, int] = defaultdict(int) + stack: list[int] = [] for event in profile["events"]: at = event["at"] * scale + frame = event["frame"] if event["type"] == "O": - label = labels[event["frame"]] - stack.append(label) - if label is not None: + for label in frame_labels[frame]: if depth[label] == 0: - opened_at[label] = at + for enclosing in open_runs.values(): + enclosing.nested.add(label) + parent = method_of(frame_names[stack[-1]]) if stack else "" + open_runs[label] = Run(at, at, parent, frozenset(open_runs)) depth[label] += 1 + stack.append(frame) continue if not stack: @@ -70,48 +130,116 @@ def measure_profile( "total below it would be wrong." ) - label = stack.pop() - if label is None: - continue + closed = stack.pop() + for label in frame_labels[closed]: + depth[label] -= 1 + if depth[label] == 0: + run = open_runs.pop(label) + run.end = at + runs[label].append(run) - depth[label] -= 1 - if depth[label] == 0: - durations[label].append(at - opened_at[label]) + return runs - return durations +def holds_frame(profile: dict[str, Any], frame_names: list[str], marker: str) -> bool: + return any(marker in frame_names[event["frame"]] for event in profile["events"]) -def read_durations(path: Path) -> tuple[dict[str, list[float]], float]: - """Durations per frame of interest and the sampled span, over an export's evented profiles.""" + +def read_trace(path: Path, skip_seconds: float) -> Trace: + """Runs per tracked frame over an export's evented profiles, past the first skip_seconds.""" with path.open(encoding="utf-8") as export: document = json.load(export) - labels = [label_of(frame["name"]) for frame in document["shared"]["frames"]] - durations: dict[str, list[float]] = {frame: [] for frame in FRAMES} - span = 0.0 - - for profile in document["profiles"]: - if profile["type"] != "evented": - continue + frame_names = [frame["name"] for frame in document["shared"]["frames"]] + frame_labels = [labels_of(name) for name in frame_names] + profiles = [profile for profile in document["profiles"] if profile["type"] == "evented"] + if not profiles: + raise ValueError("The export holds no evented profile.") + scales = [] + for profile in profiles: unit = profile.get("unit", "milliseconds") if unit not in MILLISECONDS_PER_UNIT: raise ValueError(f"Unsupported profile unit: {unit}") - - scale = MILLISECONDS_PER_UNIT[unit] - span = max(span, (profile["endValue"] - profile["startValue"]) * scale) - for frame, measured in measure_profile(profile, labels, scale).items(): - durations[frame].extend(measured) - - return durations, span - - -def report(durations: dict[str, list[float]], span: float) -> None: - print(f"sampled span {span / 1000:.1f} s") - print(f"{'frame':<28}{'calls':>8}{'total ms':>12}{'mean ms':>10}{'max ms':>10}") - + scales.append(MILLISECONDS_PER_UNIT[unit]) + + trace_start = min(p["startValue"] * s for p, s in zip(profiles, scales)) + trace_end = max(p["endValue"] * s for p, s in zip(profiles, scales)) + cutoff = trace_start + skip_seconds * 1000 + + pooled: dict[str, list[Run]] = defaultdict(list) + ui_runs: dict[str, list[Run]] | None = None + render_runs: dict[str, list[Run]] | None = None + for profile, scale in zip(profiles, scales): + runs = read_profile(profile, frame_names, frame_labels, scale) + kept = { + label: [run for run in found if run.start >= cutoff] for label, found in runs.items() + } + for label, found in kept.items(): + pooled[label].extend(found) + if ui_runs is None and holds_frame(profile, frame_names, UI_THREAD_MARKER): + ui_runs = kept + elif render_runs is None and holds_frame(profile, frame_names, RENDER_THREAD_MARKER): + render_runs = kept + + if ui_runs is None: + raise ValueError(f"No profile holds {UI_THREAD_MARKER}, so the UI thread is unknown.") + if render_runs is None: + raise ValueError( + f"No profile holds {RENDER_THREAD_MARKER}, so the render thread is unknown." + ) + + for found in pooled.values(): + found.sort(key=lambda run: run.start) + return Trace(pooled, ui_runs, render_runs, max(0.0, trace_end - cutoff)) + + +def p90(values: list[float]) -> float: + if len(values) == 1: + return values[0] + return statistics.quantiles(values, n=10, method="inclusive")[8] + + +def spread(values: list[float]) -> str: + if not values: + return f"{'-':>8}{'-':>8}{'-':>8}{0:>6}" + return ( + f"{statistics.median(values):>8.1f}{p90(values):>8.1f}" + f"{max(values):>8.1f}{len(values):>6}" + ) + + +def starts_of(runs: list[Run]) -> list[float]: + return sorted(run.start for run in runs) + + +def spacings(starts: list[float]) -> list[float]: + return [later - earlier for earlier, later in zip(starts, starts[1:])] + + +def drag_spacings(frame_starts: list[float], pointer_starts: list[float]) -> list[float]: + """Spacings between consecutive frames whose gap holds the start of a chart pointer move.""" + held = [] + for earlier, later in zip(frame_starts, frame_starts[1:]): + index = bisect.bisect_left(pointer_starts, earlier) + if index < len(pointer_starts) and pointer_starts[index] < later: + held.append(later - earlier) + return held + + +def delays_to_next(marks: list[float], frame_starts: list[float]) -> list[float]: + delays = [] + for mark in marks: + index = bisect.bisect_left(frame_starts, mark) + if index < len(frame_starts): + delays.append(frame_starts[index] - mark) + return delays + + +def report_frames(trace: Trace) -> None: + print(f"{'frame':<28}{'runs':>8}{'total ms':>12}{'mean ms':>10}{'max ms':>10}") for frame in FRAMES: - measured = durations[frame] + measured = [run.duration for run in trace.runs.get(frame, [])] if not measured: print(f"{frame:<28}{0:>8}{'-':>12}{'-':>10}{'-':>10}") continue @@ -121,20 +249,108 @@ def report(durations: dict[str, list[float]], span: float) -> None: print(f"{frame:<28}{len(measured):>8}{total:>12.1f}{mean:>10.1f}{max(measured):>10.1f}") +def report_cadence(trace: Trace) -> None: + frame_starts = starts_of(trace.render_runs.get(RENDER_ONCE, [])) + pointer_starts = starts_of(trace.ui_runs.get(CHART_POINTER_MOVED, [])) + compositor_spacings = spacings(starts_of(trace.render_runs.get(COMPOSITOR_FRAME, []))) + pan_steps = [ + run.start + for run in trace.ui_runs.get(CHART_POINTER_MOVED, []) + if CHART_WINDOW_CHANGED in run.nested + ] + + print() + rows = ( + ("RenderOnce spacing, all", spacings(frame_starts)), + ("RenderOnce spacing, drag phase", drag_spacings(frame_starts, pointer_starts)), + ("compositor frame interval", compositor_spacings), + ("pan step to next RenderOnce", delays_to_next(sorted(pan_steps), frame_starts)), + ) + print(f"{'cadence ms':<40}{'p50':>8}{'p90':>8}{'max':>8}{'n':>6}") + for name, values in rows: + print(f"{name:<40}{spread(values)}") + if compositor_spacings: + rate = 1000 / statistics.median(compositor_spacings) + print(f"compositor rate {rate:.1f} frames/s at the median interval") + + +def report_ticks(trace: Trace) -> None: + frames = len(trace.render_runs.get(RENDER_ONCE, [])) + print() + if frames == 0: + print("no RenderOnce run, so nothing is attributed per frame") + return + + ticks = trace.render_runs.get(GENERATE_TICKS, []) + tick_total = sum(run.duration for run in ticks) + print( + f"{GENERATE_TICKS}: {len(ticks)} runs, {tick_total:.1f} ms, " + f"{len(ticks) / frames:.2f} runs and {tick_total / frames:.3f} ms per RenderOnce" + ) + + by_parent: dict[str, list[Run]] = defaultdict(list) + for run in trace.render_runs.get(MEASURE_LABEL, []): + by_parent[run.parent].append(run) + measure_total = sum(run.duration for found in by_parent.values() for run in found) + print( + f"{MEASURE_LABEL}: {measure_total:.1f} ms, " + f"{measure_total / frames:.3f} ms per RenderOnce, by caller" + ) + ordered = sorted(by_parent.items(), key=lambda item: -sum(run.duration for run in item[1])) + for parent, found in ordered: + total = sum(run.duration for run in found) + print(f" {len(found):>6} runs {total:>8.1f} ms {total / frames:>7.3f} ms/frame {parent}") + + +def report_contention(trace: Trace) -> None: + stalls = [ + run.duration + for run in trace.ui_runs.get(LOCK_CONTENTION, []) + if CHART_WINDOW_CHANGED in run.enclosing + ] + print() + if not stalls: + print(f"{LOCK_CONTENTION} under {CHART_WINDOW_CHANGED}: none") + return + print( + f"{LOCK_CONTENTION} under {CHART_WINDOW_CHANGED}: " + f"{len(stalls)} runs, max {max(stalls):.2f} ms" + ) + + +def report(trace: Trace, skip_seconds: float) -> None: + print(f"sampled span {trace.span / 1000:.1f} s after the first {skip_seconds:g} s") + report_frames(trace) + report_cadence(trace) + report_ticks(trace) + report_contention(trace) + + +def parse_arguments(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("export", type=Path, help="a dotnet-trace Speedscope export") + parser.add_argument( + "--skip-seconds", + type=float, + default=0.0, + help="ignore runs that start within this many seconds of the export's start", + ) + return parser.parse_args(argv) + + def main(argv: list[str]) -> int: - if len(argv) != 2: - print(f"usage: {Path(argv[0]).name} ", file=sys.stderr) + arguments = parse_arguments(argv) + if arguments.skip_seconds < 0: + print("--skip-seconds must not be negative", file=sys.stderr) return 2 - - path = Path(argv[1]) - if not path.is_file(): - print(f"no such file: {path}", file=sys.stderr) + if not arguments.export.is_file(): + print(f"no such file: {arguments.export}", file=sys.stderr) return 1 - durations, span = read_durations(path) - report(durations, span) + trace = read_trace(arguments.export, arguments.skip_seconds) + report(trace, arguments.skip_seconds) return 0 if __name__ == "__main__": - sys.exit(main(sys.argv)) + sys.exit(main(sys.argv[1:]))