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:]))