From 2677cf697693c50b70a9c0f527de2ad6bd482512 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:28:16 +0300 Subject: [PATCH] feat(minimap): draw the drawn pen across the archive (#91) The minimap was a navigation strip with no data on it, and its window marker left the strip once the chart followed live data past the extent read at start: the marker's left edge was not clamped, and the strip's right bound stayed at the startup extent. The marker now stays inside the strip at any zoom, and the right bound follows the newest sample the chart has seen, sticky or not. The strip draws the min/max band of the pen whose axis the chart draws, from the coarsest layer that still gives it 250 columns, scaled to its own range, in the pen's colour; a break in the archive is a gap. The band is re-read at start, after an extent reload, when the drawn pen changes, and every span/1000 between 2 s and 60 s, so it trails the right bound by about a pixel. A read for a pen no longer drawn is dropped, and an outage reports once and logs each repeat. The line through the middle of the strip is gone. The end labels sit in a row under the strip, clear of the chart's time labels, and hovering the strip shows a line with its time to the minute. Claude-Session: https://claude.ai/code/session_01PfZJa7neMMKPzycFR6iuhD --- CLAUDE.md | 9 +- SemiPlot/SemiPlot.Core/Trends/BandFigure.cs | 6 + .../SemiPlot.Core/Trends/MinimapGeometry.cs | 188 +++++++ .../SemiPlot.Core/Trends/PenScaleModel.cs | 17 +- SemiPlot/SemiPlot.Core/Trends/ValueRange.cs | 17 + .../Core/Trends/MinimapGeometryTests.cs | 240 +++++++++ .../UI/Bridge/FakeDataProvider.cs | 67 ++- .../Chart/ChartNavigationControllerTests.cs | 70 +++ .../UI/Di/TrendWindowBuildTests.cs | 1 + .../UI/MainWindow/MainWindowViewTests.cs | 23 + .../UI/MainWindow/PenCatalogueApplierTests.cs | 3 +- .../UI/Minimap/MinimapBandFeedTests.cs | 345 ++++++++++++ .../UI/Minimap/MinimapHoverTests.cs | 223 ++++++++ .../UI/Minimap/MinimapPointerInputTests.cs | 200 ++----- .../UI/Minimap/MinimapStand.cs | 104 ++++ .../UI/Minimap/MinimapViewModelTests.cs | 305 ++++++++--- .../UI/Minimap/MinimapViewStand.cs | 127 +++++ .../UI/Minimap/MinimapViewTests.cs | 131 +++++ .../Chart/ChartNavigationController.cs | 24 + .../SemiPlot.UI/MainWindow/MainWindow.axaml | 1 - .../SemiPlot.UI/MainWindow/TrendWindow.cs | 2 +- SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs | 89 +++ .../SemiPlot.UI/Minimap/MinimapBandFeed.cs | 233 ++++++++ .../SemiPlot.UI/Minimap/MinimapView.axaml | 75 ++- .../SemiPlot.UI/Minimap/MinimapView.axaml.cs | 148 ++++- .../SemiPlot.UI/Minimap/MinimapViewModel.cs | 186 ++++++- SemiPlot/SemiPlot.UI/Minimap/OutageReport.cs | 33 ++ docs/architecture/bench.md | 9 +- docs/architecture/charting.md | 19 +- docs/architecture/data-integration.md | 8 +- docs/architecture/overview.md | 3 +- docs/architecture/testing-strategy.md | 10 +- docs/architecture/trend-feature-spec.md | 2 +- docs/architecture/trend-interaction.md | 154 +++++- docs/architecture/ui-theme.md | 7 +- docs/plans/backlog.md | 13 +- .../completed/20261002-minimap-overview.md | 508 ++++++++++++++++++ 37 files changed, 3226 insertions(+), 374 deletions(-) create mode 100644 SemiPlot/SemiPlot.Core/Trends/BandFigure.cs create mode 100644 SemiPlot/SemiPlot.Core/Trends/ValueRange.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapBandFeedTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapHoverTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapStand.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewStand.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs create mode 100644 SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs create mode 100644 SemiPlot/SemiPlot.UI/Minimap/MinimapBandFeed.cs create mode 100644 SemiPlot/SemiPlot.UI/Minimap/OutageReport.cs create mode 100644 docs/plans/completed/20261002-minimap-overview.md diff --git a/CLAUDE.md b/CLAUDE.md index 4560db3..7561f4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,8 +122,7 @@ Two projects, split on one axis: needs a container or not. `SemiPlot.Tests.Unit/UI/HeadlessInput.cs` (`Click`, `Clear`, `Type`, `Press`) on the `TopLevel` that shows the control; no test class carries a helper of its own for these four gestures. `git grep -nE "static void (Click|Clear|Type|Press)\(" -- SemiPlot/SemiPlot.Tests.Unit` prints only - `HeadlessInput.cs` lines. A hover or a held mouse button, which only `ThemeTests` drives, is outside - the rule. + `HeadlessInput.cs` lines. A hover, a drag or a held mouse button is outside the rule. - Neither project references the other. Core, `SemiPlot.DataSource.Postgres` and `SemiPlot.UI` each name both in `InternalsVisibleTo`. - `SemiPlot.Tests.Unit` sets `failSkips` in `xunit.runner.json`, so no gated test may live there. @@ -134,7 +133,11 @@ Two projects, split on one axis: needs a container or not. through `ScheduleAsync` and waits on `Schedule(ReadInterval)` before every read, and `ImmediateScheduler` runs each wait by sleeping on the calling thread, so `Start()` never returns. A test builds it over a `TestScheduler` and advances one tick after `Start()` - (`UI/MainWindow/MainWindowTestBuilder.cs`). The chart's redraw hangs nothing: it is a one-shot + (`UI/MainWindow/MainWindowTestBuilder.cs`). `MinimapViewModel` hangs the same way: each band read + 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` diff --git a/SemiPlot/SemiPlot.Core/Trends/BandFigure.cs b/SemiPlot/SemiPlot.Core/Trends/BandFigure.cs new file mode 100644 index 0000000..e114b71 --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Trends/BandFigure.cs @@ -0,0 +1,6 @@ +namespace SemiPlot.Core.Trends; + +/// One unbroken run of the minimap band in control coordinates: max forward, min back, and the centre. +public sealed record BandFigure(IReadOnlyList Outline, IReadOnlyList CenterLine); + +public readonly record struct BandPoint(double X, double Y); diff --git a/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs b/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs index f600b92..724668e 100644 --- a/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs +++ b/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs @@ -1,7 +1,13 @@ namespace SemiPlot.Core.Trends; +/// Where the hover time sits in the label row, and which end label it hides. +public readonly record struct HoverTimePlacement(double Left, bool CoversFirst, bool CoversLast); + public static class MinimapGeometry { + private const double FlatBandPadding = 1.0; + private const double LoneColumnWidth = 2.0; + // A zero-or-negative extent span (no data yet) yields the full strip so the highlight never collapses. public static (double Start, double Width) WindowFraction( DateTime extentFirst, @@ -21,10 +27,192 @@ public static (double Start, double Width) WindowFraction( return (start, end - start); } + public static (double Left, double Width) MarkerSpan( + double startFraction, + double widthFraction, + double stripWidth, + double minimumWidth) + { + if (stripWidth <= 0.0) + { + return (0.0, 0.0); + } + + var width = Math.Min(stripWidth, Math.Max(minimumWidth, widthFraction * stripWidth)); + + return (SpanLeftWithin(startFraction * stripWidth, width, stripWidth), width); + } + + /// Moves a span's left edge so it lies inside [0, limit]; a wider span starts at 0. + public static double SpanLeftWithin(double left, double width, double limit) + { + return Math.Max(0.0, Math.Min(left, limit - width)); + } + + /// Centres the hover time on the pointer in a label row whose end labels sit at its edges. + public static HoverTimePlacement PlaceHoverTime( + double pointerX, + double timeWidth, + double rowWidth, + double firstLabelWidth, + double lastLabelWidth, + double coverDistance) + { + var left = SpanLeftWithin(pointerX - (timeWidth / 2.0), timeWidth, rowWidth); + + return new HoverTimePlacement( + left, + CoversFirst: left < firstLabelWidth + coverDistance, + CoversLast: left + timeWidth > rowWidth - lastLabelWidth - coverDistance); + } + + public static DateTime RightBound(DateTime extentLast, DateTime? newestSample) + { + return newestSample > extentLast ? newestSample.Value : extentLast; + } + public static DateTime TimeAtFraction(DateTime extentFirst, DateTime extentLast, double fraction) { var span = extentLast - extentFirst; return extentFirst + (span * Math.Clamp(fraction, 0.0, 1.0)); } + + public static IReadOnlyList BandFigures( + PenHistoryEnvelope band, + DateTime first, + DateTime last, + double width, + double height) + { + var spanSeconds = (last - first).TotalSeconds; + if (spanSeconds <= 0.0 || width <= 0.0 || height <= 0.0 + || !TryReadBandRange(band, first, last, out var bottom, out var top)) + { + return []; + } + + var scale = new BandScale(first, spanSeconds, width, bottom, top, height); + var figures = new List(); + var runStart = -1; + + for (var index = 0; index <= band.Timestamps.Count; index++) + { + if (index < band.Timestamps.Count && IsDrawn(band, index, first, last)) + { + runStart = runStart < 0 ? index : runStart; + continue; + } + + if (runStart >= 0) + { + figures.Add(Figure(band, runStart, index, scale)); + runStart = -1; + } + } + + return figures; + } + + private static bool TryReadBandRange( + PenHistoryEnvelope band, + DateTime first, + DateTime last, + out double bottom, + out double top) + { + bottom = double.MaxValue; + top = double.MinValue; + var hasValue = false; + + for (var index = 0; index < band.Timestamps.Count; index++) + { + if (IsDrawn(band, index, first, last)) + { + hasValue |= ValueRange.Widen(band.Min[index], isLogarithmic: false, ref bottom, ref top); + hasValue |= ValueRange.Widen(band.Max[index], isLogarithmic: false, ref bottom, ref top); + } + } + + if (hasValue && bottom == top) + { + bottom -= FlatBandPadding; + top += FlatBandPadding; + } + + return hasValue; + } + + private static bool IsDrawn(PenHistoryEnvelope band, int index, DateTime first, DateTime last) + { + var timestamp = band.Timestamps[index]; + + return timestamp >= first && timestamp <= last + && double.IsFinite(band.Min[index]) + && double.IsFinite(band.Max[index]) + && double.IsFinite(band.Center[index]); + } + + private static BandFigure Figure(PenHistoryEnvelope band, int start, int end, BandScale scale) + { + if (end - start == 1) + { + return LoneColumn(band, start, scale); + } + + var outline = new List(2 * (end - start)); + var centerLine = new List(end - start); + + for (var index = start; index < end; index++) + { + var x = scale.X(band.Timestamps[index]); + outline.Add(new BandPoint(x, scale.Y(band.Max[index]))); + centerLine.Add(new BandPoint(x, scale.Y(band.Center[index]))); + } + + for (var index = end - 1; index >= start; index--) + { + outline.Add(new BandPoint(scale.X(band.Timestamps[index]), scale.Y(band.Min[index]))); + } + + return new BandFigure(outline, centerLine); + } + + private static BandFigure LoneColumn(PenHistoryEnvelope band, int index, BandScale scale) + { + var center = scale.X(band.Timestamps[index]); + var left = SpanLeftWithin(center - (LoneColumnWidth / 2.0), LoneColumnWidth, scale.Width); + var right = Math.Min(scale.Width, left + LoneColumnWidth); + var top = scale.Y(band.Max[index]); + var bottom = scale.Y(band.Min[index]); + var middle = scale.Y(band.Center[index]); + + return new BandFigure( + [ + new BandPoint(left, top), + new BandPoint(right, top), + new BandPoint(right, bottom), + new BandPoint(left, bottom) + ], + [new BandPoint(left, middle), new BandPoint(right, middle)]); + } + + private readonly record struct BandScale( + DateTime First, + double SpanSeconds, + double Width, + double Bottom, + double Top, + double Height) + { + public double X(DateTime timestamp) + { + return (timestamp - First).TotalSeconds / SpanSeconds * Width; + } + + public double Y(double value) + { + return (Top - value) / (Top - Bottom) * Height; + } + } } diff --git a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs index e81f94a..94784ea 100644 --- a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs +++ b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs @@ -103,26 +103,13 @@ private static bool TryReadRange( break; } - hasValue |= Widen(envelope.Min[index], isLogarithmic, ref min, ref max); - hasValue |= Widen(envelope.Max[index], isLogarithmic, ref min, ref max); + hasValue |= ValueRange.Widen(envelope.Min[index], isLogarithmic, ref min, ref max); + hasValue |= ValueRange.Widen(envelope.Max[index], isLogarithmic, ref min, ref max); } return hasValue; } - private static bool Widen(double value, bool isLogarithmic, ref double min, ref double max) - { - if (double.IsNaN(value) || (isLogarithmic && value <= 0.0)) - { - return false; - } - - min = Math.Min(min, value); - max = Math.Max(max, value); - - return true; - } - // PenHistoryEnvelope enforces strictly ascending timestamps. private static int FirstAtOrAfter(IReadOnlyList timestamps, DateTime windowStart) { diff --git a/SemiPlot/SemiPlot.Core/Trends/ValueRange.cs b/SemiPlot/SemiPlot.Core/Trends/ValueRange.cs new file mode 100644 index 0000000..6ac7996 --- /dev/null +++ b/SemiPlot/SemiPlot.Core/Trends/ValueRange.cs @@ -0,0 +1,17 @@ +namespace SemiPlot.Core.Trends; + +internal static class ValueRange +{ + public static bool Widen(double value, bool isLogarithmic, ref double min, ref double max) + { + if (double.IsNaN(value) || (isLogarithmic && value <= 0.0)) + { + return false; + } + + min = Math.Min(min, value); + max = Math.Max(max, value); + + return true; + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs index 4b9e378..afa3536 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs @@ -11,6 +11,12 @@ namespace SemiPlot.Tests.Unit.Core.Trends; [Trait("Category", "Unit")] public sealed class MinimapGeometryTests { + private const double StripWidth = 900.0; + private const double MinimumMarkerWidth = 6.0; + private const double BandWidth = 400.0; + private const double BandHeight = 30.0; + private const double LabelWidth = 60.0; + private const double CoverDistance = 6.0; private static readonly DateTime _first = new(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime _last = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc); @@ -56,6 +62,230 @@ public void WindowFraction_ZeroSpanExtent_ReturnsFullStrip() width.Should().Be(1.0); } + [Theory] + [InlineData(1.0)] + [InlineData(0.999)] + public void MarkerSpan_AtTheRightEnd_StaysInsideTheStrip(double startFraction) + { + var (left, width) = MinimapGeometry.MarkerSpan(startFraction, 0.0, StripWidth, MinimumMarkerWidth); + + width.Should().Be(MinimumMarkerWidth); + (left + width).Should().BeLessThanOrEqualTo(StripWidth); + } + + [Fact] + public void MarkerSpan_WindowWiderThanTheStrip_FillsItExactly() + { + var (left, width) = MinimapGeometry.MarkerSpan(-0.5, 2.0, StripWidth, MinimumMarkerWidth); + + left.Should().Be(0.0); + width.Should().Be(StripWidth); + } + + [Fact] + public void MarkerSpan_ZeroStripWidth_IsEmpty() + { + var span = MinimapGeometry.MarkerSpan(0.5, 0.1, 0.0, MinimumMarkerWidth); + + span.Should().Be((0.0, 0.0)); + } + + [Fact] + public void MarkerSpan_WindowAtTheLeftEdge_KeepsLeftAtZero() + { + var (left, width) = MinimapGeometry.MarkerSpan(0.0, 0.001, StripWidth, MinimumMarkerWidth); + + left.Should().Be(0.0); + width.Should().Be(MinimumMarkerWidth); + } + + [Theory] + [InlineData(100.0, 100.0)] + [InlineData(-5.0, 0.0)] + [InlineData(899.5, StripWidth - 1.0)] + public void SpanLeftWithin_KeepsAOnePixelSpanInsideTheLimit(double left, double expected) + { + MinimapGeometry.SpanLeftWithin(left, 1.0, StripWidth).Should().Be(expected); + } + + [Fact] + public void SpanLeftWithin_ASpanWiderThanTheLimit_StartsAtItsLeftEdge() + { + MinimapGeometry.SpanLeftWithin(10.0, StripWidth + 50.0, StripWidth).Should().Be(0.0); + } + + [Fact] + public void PlaceHoverTime_InTheMiddle_CentresOnThePointerAndCoversNeitherEndLabel() + { + var placement = MinimapGeometry.PlaceHoverTime( + 450.0, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance); + + placement.Should().Be( + new HoverTimePlacement(450.0 - (LabelWidth / 2.0), CoversFirst: false, CoversLast: false)); + } + + [Theory] + [InlineData(0.0, 0.0)] + [InlineData(StripWidth, StripWidth - LabelWidth)] + public void PlaceHoverTime_AtAnEnd_StaysInTheRowAndCoversThatEndLabelOnly(double pointerX, double expectedLeft) + { + var placement = MinimapGeometry.PlaceHoverTime( + pointerX, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance); + + var atLeftEnd = pointerX == 0.0; + placement.Should().Be(new HoverTimePlacement(expectedLeft, CoversFirst: atLeftEnd, CoversLast: !atLeftEnd)); + } + + [Theory] + [InlineData(CoverDistance - 3.0, true)] + [InlineData(CoverDistance + 1.0, false)] + public void PlaceHoverTime_ATimeWithinTheCoverDistanceOfAnEndLabel_CoversIt(double distance, bool covers) + { + var nearFirst = LabelWidth + distance + (LabelWidth / 2.0); + var nearLast = StripWidth - LabelWidth - distance - (LabelWidth / 2.0); + + MinimapGeometry.PlaceHoverTime(nearFirst, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance) + .CoversFirst.Should().Be(covers); + MinimapGeometry.PlaceHoverTime(nearLast, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance) + .CoversLast.Should().Be(covers); + } + + [Fact] + public void RightBound_IsTheLaterOfTheExtentAndTheNewestSample() + { + var newer = _last + TimeSpan.FromMinutes(5.0); + var older = _last - TimeSpan.FromMinutes(5.0); + + MinimapGeometry.RightBound(_last, newer).Should().Be(newer); + MinimapGeometry.RightBound(_last, older).Should().Be(_last); + MinimapGeometry.RightBound(_last, null).Should().Be(_last); + } + + [Fact] + public void ABandWithABreak_DrawsTwoFigures() + { + var band = Envelope( + (0.0, 1.0, 3.0, 2.0), + (1.0, 2.0, 4.0, 3.0), + (2.0, double.NaN, double.NaN, double.NaN), + (3.0, 0.0, 2.0, 1.0), + (4.0, 1.0, 5.0, 3.0)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight); + + // The drawn range is [0, 5] over 30 px, so a value v sits at 6 * (5 - v). + figures.Should().HaveCount(2); + figures[0].Outline.Should().Equal( + new BandPoint(0.0, 12.0), + new BandPoint(100.0, 6.0), + new BandPoint(100.0, 18.0), + new BandPoint(0.0, 24.0)); + figures[0].CenterLine.Should().Equal(new BandPoint(0.0, 18.0), new BandPoint(100.0, 12.0)); + figures[1].Outline.Should().Equal( + new BandPoint(300.0, 18.0), + new BandPoint(400.0, 0.0), + new BandPoint(400.0, 24.0), + new BandPoint(300.0, BandHeight)); + figures[1].CenterLine.Should().Equal(new BandPoint(300.0, 24.0), new BandPoint(400.0, 12.0)); + } + + [Fact] + public void ALoneColumnBetweenBreaks_DrawsAFigureTwoPixelsWide() + { + var band = Envelope( + (0.0, 1.0, 3.0, 2.0), + (1.0, 2.0, 4.0, 3.0), + (2.0, double.NaN, double.NaN, double.NaN), + (3.0, 2.0, 4.0, 3.0), + (4.0, double.NaN, double.NaN, double.NaN)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight); + + figures.Should().HaveCount(2); + figures[1].Outline.Select(point => point.X).Should().Equal(299.0, 301.0, 301.0, 299.0); + figures[1].CenterLine.Select(point => point.X).Should().Equal(299.0, 301.0); + } + + [Fact] + public void AnInfiniteColumn_IsABreakAndEveryPointStaysFinite() + { + var band = Envelope( + (0.0, 0.0, 2.0, 1.0), + (1.0, 1.0, 5.0, 3.0), + (2.0, double.NegativeInfinity, double.PositiveInfinity, 0.0), + (3.0, 1.0, 3.0, 2.0), + (4.0, 0.0, 5.0, 2.0)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight); + + figures.Should().HaveCount(2); + figures.SelectMany(figure => figure.Outline.Concat(figure.CenterLine)) + .Should().OnlyContain(point => double.IsFinite(point.Y)); + } + + [Fact] + public void AColumnWithNoCentre_IsLeftOutOfTheRange() + { + var band = Envelope( + (0.0, 0.0, 5.0, 2.0), + (1.0, 1.0, 4.0, 2.0), + (2.0, -100.0, 100.0, double.NaN), + (3.0, 1.0, 4.0, 2.0), + (4.0, 0.0, 5.0, 3.0)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight); + + var outlineYs = figures.SelectMany(figure => figure.Outline).Select(point => point.Y).ToArray(); + outlineYs.Min().Should().Be(0.0, "the drawn maximum 5 maps to the top"); + outlineYs.Max().Should().Be(BandHeight, "the drawn minimum 0 maps to the bottom"); + } + + [Fact] + public void AFlatPen_DrawsAMidLineBand() + { + var band = Envelope((0.0, 5.0, 5.0, 5.0), (1.0, 5.0, 5.0, 5.0)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(1.0), BandWidth, BandHeight); + + figures.Should().ContainSingle(); + figures[0].Outline.Should().OnlyContain(point => point.Y == BandHeight / 2.0); + figures[0].CenterLine.Should().OnlyContain(point => point.Y == BandHeight / 2.0); + } + + [Fact] + public void AnAllNaNBand_DrawsNothing() + { + var band = Envelope( + (0.0, double.NaN, double.NaN, double.NaN), + (1.0, double.NaN, double.NaN, double.NaN)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(1.0), BandWidth, BandHeight); + + figures.Should().BeEmpty(); + } + + [Fact] + public void BandFigures_DropColumnsOutsideTheBoundsAndMapTheMaxToTheTop() + { + var band = Envelope( + (-1.0, -100.0, 100.0, 0.0), + (0.0, 0.0, 10.0, 5.0), + (1.0, 2.0, 8.0, 5.0), + (2.0, -100.0, 100.0, 0.0)); + + var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(1.0), BandWidth, BandHeight); + + figures.Should().ContainSingle(); + figures[0].Outline.Should().Equal( + new BandPoint(0.0, 0.0), + new BandPoint(BandWidth, 6.0), + new BandPoint(BandWidth, 24.0), + new BandPoint(0.0, BandHeight)); + figures[0].CenterLine.Should().Equal( + new BandPoint(0.0, BandHeight / 2.0), + new BandPoint(BandWidth, BandHeight / 2.0)); + } + [Fact] public void TimeAtFraction_Half_ReturnsExtentMidpoint() { @@ -74,4 +304,14 @@ public void TimeAtFraction_OutOfRange_ClampsToExtentEdge(double fraction) time.Should().BeOnOrAfter(_first); time.Should().BeOnOrBefore(_last); } + + private static PenHistoryEnvelope Envelope(params (double Days, double Min, double Max, double Center)[] columns) + { + return new PenHistoryEnvelope( + 1, + [.. columns.Select(column => _first.AddDays(column.Days))], + [.. columns.Select(column => column.Min)], + [.. columns.Select(column => column.Max)], + [.. columns.Select(column => column.Center)]); + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs index 085f4f6..e978e86 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs @@ -9,6 +9,7 @@ using SemiPlot.Core.Data; using SemiPlot.Core.Data.Errors; using SemiPlot.Core.Trends; +using SemiPlot.UI.Minimap; namespace SemiPlot.Tests.Unit.UI.Bridge; @@ -29,6 +30,10 @@ internal sealed class FakeDataProvider( // instead of waiting for a tick that this fake never runs. private readonly Subject _connectionFaults = new(); + // A test waits for a query on another thread and then reads it, so the log is appended and copied whole. + private readonly Lock _historyQueriesGate = new(); + private readonly List _historyQueries = []; + public DateTime ArchiveFirstUtc { get; set; } = new(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc); public DateTime ArchiveLastUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -75,19 +80,42 @@ internal sealed class FakeDataProvider( // for a pen holding no row in the window. A requested pen missing from the result is not an error. public HashSet OmittedPenIds { get; } = []; - public int HistoryQueryCount { get; private set; } + // A history read that throws instead of answering, the shape of a defect rather than of an outage. + public Exception? HistoryReadException { get; set; } + + public IReadOnlyList ChartHistoryQueries => + [.. HistoryQueries.Where(query => query.TargetColumnCount != MinimapBandFeed.MinimapColumns)]; + + public IReadOnlyList BandHistoryQueries => + [.. HistoryQueries.Where(query => query.TargetColumnCount == MinimapBandFeed.MinimapColumns)]; + + /// The chart's query count; like every LastQueried member below, it ignores the band's reads. + public int HistoryQueryCount => ChartHistoryQueries.Count; + + public IReadOnlyList? LastQueriedPenIds => LastHistoryQuery?.PenIds; - public IReadOnlyList? LastQueriedPenIds { get; private set; } + public AggregationLayer? LastQueriedLayer => LastHistoryQuery?.Layer; - public AggregationLayer? LastQueriedLayer { get; private set; } + public DateTime? LastQueriedFromUtc => LastHistoryQuery?.FromUtc; - public DateTime? LastQueriedFromUtc { get; private set; } + public DateTime? LastQueriedToUtc => LastHistoryQuery?.ToUtc; - public DateTime? LastQueriedToUtc { get; private set; } + public int? LastQueriedTargetColumnCount => LastHistoryQuery?.TargetColumnCount; - public int? LastQueriedTargetColumnCount { get; private set; } + public CancellationToken LastQueriedCancellationToken => LastHistoryQuery?.CancellationToken ?? default; - public CancellationToken LastQueriedCancellationToken { get; private set; } + private HistoryQuery? LastHistoryQuery => ChartHistoryQueries is [.., var last] ? last : null; + + private IReadOnlyList HistoryQueries + { + get + { + lock (_historyQueriesGate) + { + return [.. _historyQueries]; + } + } + } public int PensQueryCount { get; private set; } @@ -211,14 +239,15 @@ public Task>> QueryHistoryAsync( int targetColumnCount, CancellationToken cancellationToken = default) { - // The count moves last: a test that waits for it on another thread then reads the query it counts. - LastQueriedPenIds = penIds; - LastQueriedLayer = layer; - LastQueriedFromUtc = fromUtc; - LastQueriedToUtc = toUtc; - LastQueriedTargetColumnCount = targetColumnCount; - LastQueriedCancellationToken = cancellationToken; - HistoryQueryCount++; + lock (_historyQueriesGate) + { + _historyQueries.Add(new HistoryQuery(penIds, fromUtc, toUtc, layer, targetColumnCount, cancellationToken)); + } + + if (HistoryReadException is { } readException) + { + return Task.FromException>>(readException); + } if (FailHistory) { @@ -288,3 +317,11 @@ private ArchiveExtent CatalogueExtent() return Pens.Count == 0 ? ArchiveExtent.Empty : new ArchiveExtent(ArchiveFirstUtc, ArchiveLastUtc); } } + +internal sealed record HistoryQuery( + IReadOnlyList PenIds, + DateTime FromUtc, + DateTime ToUtc, + AggregationLayer Layer, + int TargetColumnCount, + CancellationToken CancellationToken); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs index 935d466..c0261c9 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs @@ -101,6 +101,76 @@ public void OnLiveEdge_WhenNotSticky_LeavesWindowUnchanged() controller.To.Should().Be(toBefore); } + [Fact] + public void NewestSample_OnAFreshController_IsNull() + { + var controller = new ChartNavigationController(); + + controller.NewestSample.Should().BeNull("the constructor's wall clock is no sample"); + } + + [Fact] + public void TrackDataExtents_SetsTheNewestSampleAndRaisesItsMove() + { + var controller = new ChartNavigationController(); + var moves = new List(); + controller.NewestSampleMoved += (_, sample) => moves.Add(sample); + + controller.TrackDataExtents(_first, _last); + + controller.NewestSample.Should().Be(_last); + moves.Should().Equal(_last); + } + + [Fact] + public void OnLiveEdge_SetsTheNewestSampleAndRaisesEachMove_StickyOrNot() + { + var controller = Loaded(); + var moves = new List(); + controller.NewestSampleMoved += (_, sample) => moves.Add(sample); + var whileSticky = _last.AddMinutes(1.0); + var whileDetached = _last.AddMinutes(2.0); + + controller.OnLiveEdge(whileSticky); + controller.SetSticky(false); + controller.OnLiveEdge(whileDetached); + + controller.NewestSample.Should().Be(whileDetached); + moves.Should().Equal(whileSticky, whileDetached); + } + + [Fact] + public void NewestSampleMoved_SeesTheMovedWindowAndPrecedesWindowChanged() + { + var controller = new ChartNavigationController(); + var raised = new List<(string Event, DateTime To)>(); + controller.NewestSampleMoved += (_, _) => raised.Add((nameof(controller.NewestSampleMoved), controller.To)); + controller.WindowChanged += (_, window) => raised.Add((nameof(controller.WindowChanged), window.To)); + var advanced = _last.AddMinutes(2.0); + + controller.TrackDataExtents(_first, _last); + controller.OnLiveEdge(advanced); + + raised.Should().Equal( + (nameof(controller.NewestSampleMoved), _last), + (nameof(controller.WindowChanged), _last), + (nameof(controller.NewestSampleMoved), advanced), + (nameof(controller.WindowChanged), advanced)); + } + + [Fact] + public void OnLiveEdge_WithASampleOlderThanTheNewest_LeavesItAndRaisesNothing() + { + var controller = Loaded(); + var moves = 0; + controller.NewestSampleMoved += (_, _) => moves++; + + controller.OnLiveEdge(_last.AddMinutes(-1.0)); + + controller.NewestSample.Should().Be(_last); + moves.Should().Be(0); + } + // Half a layer's ceiling sits well inside it, clear of the 25% zoom quantisation step and the 10% // hysteresis band. The Day row's 1.2x hour ceiling stays inside the 365-day maximum window at 1024 // columns, so the answer comes from the ladder, not from TrendNavigationModel truncating the width. diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs index f8df1eb..df2c1b5 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs @@ -56,6 +56,7 @@ public async Task TheChartAndTheMinimap_ReportIntoThePanelTheWindowShows() var panel = container.GetRequiredService(); + // The failed extent leaves the minimap band unread, so the history failure here is the chart's own. panel.Entries.Select(entry => entry.View.Title).Should().Contain( [ ArchiveFailureMapper.Map(new Error("Forced history failure.")).Title, diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs index 10c269c..1388679 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs @@ -16,11 +16,13 @@ using SemiPlot.Core.Data; using SemiPlot.Core.Trends; +using SemiPlot.Tests.Unit.UI.Minimap; using SemiPlot.Tests.Unit.UI.PenEditor; using SemiPlot.Tests.Unit.UI.Settings; using SemiPlot.UI.Legend; using SemiPlot.UI.MainWindow; using SemiPlot.UI.Messages; +using SemiPlot.UI.Minimap; using SemiPlot.UI.PenEditor; using SemiPlot.UI.Settings; @@ -212,6 +214,27 @@ public void EveryViewMenuRow_FollowsItsOwnFlagOnTheRealisedWindow() } } + [AvaloniaFact] + public void TheMinimapRow_ShowsTheWholeLabelRowUnderTheStrip() + { + using var stand = NewWindowStand(); + var window = new MainWindowView { DataContext = stand.ViewModel }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + var row = window.FindControl("MinimapRow")!; + var minimap = window.FindControl("Minimap")!; + var strip = minimap.FindControl("StripCanvas")!; + var lastLabel = minimap.FindControl("ExtentLastLabel")!; + + var label = MinimapViewStand.AreaIn(row, lastLabel); + + label.Height.Should().BePositive("the label row holds one line of text"); + label.Top.Should().BeGreaterThanOrEqualTo( + MinimapViewStand.AreaIn(row, strip).Bottom, "the label row sits under the strip"); + label.Bottom.Should().BeLessThanOrEqualTo( + row.Bounds.Height, "the minimap row shows the whole label row"); + } + [AvaloniaFact] public void LegendPanelWidth_OpensExpandedAndThenFollowsThePanelState() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs index f7788b2..f12c78d 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs @@ -169,7 +169,8 @@ public void ADeltaGivingAnEmptyChartItsFirstPens_WhenTheExtentReadFails_StillApp chart.HasNoPens.Should().BeFalse(); chart.Navigation.FirstSample.Should().Be(firstSampleBefore, "no extent came back to seed it with"); - provider.ExtentQueryCount.Should().Be(extentQueriesAtStart + 1); + provider.ExtentQueryCount.Should().Be( + extentQueriesAtStart + 2, "the delta reads the extent, and the minimap reads it once more on the next sample"); provider.LastQueriedPenIds.Should().Equal(1); viewModel.MessagePanel.Entries.Should().ContainSingle() .Which.View.Title.Should().Be(Resources.FailureArchiveReadFailedTitle); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapBandFeedTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapBandFeedTests.cs new file mode 100644 index 0000000..e71b2cb --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapBandFeedTests.cs @@ -0,0 +1,345 @@ +using Avalonia.Headless.XUnit; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Extensions.Logging; + +using SemiPlot.Core.Trends; +using SemiPlot.Tests.Unit.UI.Messages; +using SemiPlot.UI.Chart; +using SemiPlot.UI.Messages; +using SemiPlot.UI.Minimap; + +using Xunit; + +namespace SemiPlot.Tests.Unit.UI.Minimap; + +[Trait("Component", "UI")] +[Trait("Area", "Bridge")] +[Trait("Category", "Unit")] +public sealed class MinimapBandFeedTests +{ + private static readonly DateTime _extentFirst = MinimapStand.ExtentFirst; + private static readonly DateTime _extentLast = MinimapStand.ExtentLast; + + [AvaloniaFact] + public async Task ASevenDayExtent_ReadsTheDrawnPenAtTheHourLayer() + { + using var stand = new MinimapStand(); + stand.ShowPens(); + stand.Chart.DrawnPenId.Should().Be(1); + + await stand.LoadExtentAsync(); + stand.LandBandRead(); + + var read = stand.Provider.BandHistoryQueries.Should().ContainSingle().Subject; + read.PenIds.Should().Equal(1); + read.FromUtc.Should().Be(_extentFirst); + read.ToUtc.Should().Be(_extentLast); + read.Layer.Should().Be(AggregationLayer.Hour); + var band = stand.Band; + band.Should().NotBeNull(); + band!.PenId.Should().Be(1); + band.Timestamps.Should().Equal(_extentFirst, _extentLast); + } + + [AvaloniaFact] + public async Task AReloadedExtent_ReadsTheBandAgain() + { + using var stand = await MinimapStand.WithABandAsync(); + + await stand.LoadExtentAsync(); + stand.LandBandRead(); + + stand.Provider.BandHistoryQueries.Should().HaveCount(2); + } + + [AvaloniaFact] + public async Task ASupersededBandRead_IsCancelled() + { + using var stand = new MinimapStand(); + var provider = stand.Provider; + stand.ShowPens(); + provider.GatedLayer = AggregationLayer.Hour; + await stand.LoadExtentAsync(); + var held = provider.BandHistoryQueries.Should().ContainSingle().Subject; + var heldGate = provider.HistoryGate; + provider.HistoryGate = new TaskCompletionSource>>(); + + stand.Chart.SetActivePen(2).Should().BeTrue(); + + held.CancellationToken.IsCancellationRequested.Should().BeTrue(); + provider.BandHistoryQueries[^1].PenIds.Should().Equal(2); + provider.HistoryGate.SetResult(Result.Ok>([Envelope(2)])); + heldGate.TrySetResult(Result.Ok>([Envelope(1)])); + stand.LandBandRead(); + stand.LandBandRead(); + + stand.Band!.PenId.Should().Be(2); + stand.Panel.Entries.Should().BeEmpty("a cancelled read is no failure"); + } + + [AvaloniaFact] + public async Task AnOutage_ReportsOnceAndLogsEveryRepeat() + { + var logger = new RecordingLogger(); + using var stand = new MinimapStand(logger); + stand.ShowPens(); + stand.Provider.FailHistory = true; + await stand.LoadExtentAsync(); + stand.LandBandRead(); + + stand.Panel.Entries.Should().ContainSingle(); + + // The catalogue sync's own entry tops the panel between two band reads, as it does every 5 s during an + // outage, so a band repeat would not coalesce with the band's first entry. + for (var repeat = 0; repeat < 2; repeat++) + { + stand.Panel.Report(ArchiveFailureMapper.Map(new Error("The pen catalogue read failed."))); + stand.AdvanceToNextBandRead(); + } + + stand.Panel.Entries.Should().HaveCount(2, "the band's repeats add no entry"); + logger.Levels.Count(level => level == LogLevel.Warning).Should().Be(2); + + stand.Provider.FailHistory = false; + stand.AdvanceToNextBandRead(); + stand.Provider.FailHistory = true; + stand.AdvanceToNextBandRead(); + + stand.Panel.Entries.Should().HaveCount(3, "a success arms the report again"); + } + + [AvaloniaFact] + public async Task NoDrawnPen_LeavesTheBandEmptyAndReadsNothing() + { + using var stand = new MinimapStand(); + + await stand.LoadExtentAsync(); + stand.LandBandRead(); + + stand.ViewModel.HasExtent.Should().BeTrue(); + stand.Band.Should().BeNull(); + stand.ViewModel.BandFeed.BandColor.Should().BeNull(); + stand.Provider.BandHistoryQueries.Should().BeEmpty(); + } + + [AvaloniaFact] + public async Task HidingTheDrawnPen_ClearsTheBandAndStopsTheReads() + { + using var stand = new MinimapStand(); + stand.Provider.Pens = [stand.Provider.Pens[0]]; + stand.ShowPens(); + await stand.LoadExtentAsync(); + stand.LandBandRead(); + stand.Band.Should().NotBeNull(); + + stand.Chart.SetPenVisibility(1, isVisible: false); + stand.LandBandRead(); + stand.Scheduler.AdvanceBy(TimeSpan.FromMinutes(2.0).Ticks); + + stand.Chart.DrawnPenId.Should().BeNull(); + stand.Band.Should().BeNull(); + stand.Provider.BandHistoryQueries.Should().ContainSingle(); + + stand.Chart.SetPenVisibility(1, isVisible: true); + stand.LandBandRead(); + + stand.Provider.BandHistoryQueries.Should().HaveCount(2, "showing the pen again reads its band once"); + stand.Band.Should().NotBeNull(); + } + + [AvaloniaFact] + public async Task ADrawnPenIdRaiseWithTheSameId_IssuesNoRead() + { + using var stand = await MinimapStand.WithABandAsync(); + + stand.Chart.SetPenVisibility(2, isVisible: false); + stand.LandBandRead(); + + stand.Chart.DrawnPenId.Should().Be(1); + stand.Provider.BandHistoryQueries.Should().ContainSingle(); + } + + [AvaloniaFact] + public async Task TheNextRead_LandsTheReadDelayAfterTheLastOneLanded() + { + using var stand = new MinimapStand(); + stand.Provider.ArchiveFirstUtc = _extentLast - TimeSpan.FromHours(1.0); + stand.ShowPens(); + await stand.LoadExtentAsync(); + stand.LandBandRead(); + var delay = MinimapBandFeed.NextReadDelay(stand.ViewModel.ExtentLast - stand.ViewModel.ExtentFirst); + delay.Should().Be(TimeSpan.FromSeconds(3.6)); + + stand.Scheduler.AdvanceBy(delay.Ticks - 1); + stand.Provider.BandHistoryQueries.Should().ContainSingle(); + + stand.Scheduler.AdvanceBy(1); + stand.Provider.BandHistoryQueries.Should().HaveCount(2); + } + + [AvaloniaFact] + public async Task AThrowingRead_ReportsAndTheNextScheduledReadStillRuns() + { + using var stand = new MinimapStand(); + var thrown = new InvalidOperationException("the band read threw"); + stand.ShowPens(); + stand.Provider.HistoryReadException = thrown; + await stand.LoadExtentAsync(); + stand.LandBandRead(); + + stand.Panel.Entries.Should().ContainSingle() + .Which.View.Should().Be(ArchiveFailureMapper.Map(new ExceptionalError(thrown))); + + stand.Provider.HistoryReadException = null; + stand.AdvanceToNextBandRead(); + + stand.Provider.BandHistoryQueries.Should().HaveCount(2); + stand.Band.Should().NotBeNull(); + } + + [AvaloniaFact] + public async Task ARecolourOfTheDrawnPen_ReachesBandColorWithoutARead() + { + using var stand = await MinimapStand.WithABandAsync(); + var pens = stand.Provider.Pens; + stand.ViewModel.BandFeed.BandColor.Should().Be("#ff0000"); + + stand.Chart.ApplyCatalogue([pens[0] with { Color = "#0000ff" }, pens[1]]); + stand.LandBandRead(); + + stand.ViewModel.BandFeed.BandColor.Should().Be("#0000ff"); + stand.Provider.BandHistoryQueries.Should().ContainSingle(); + } + + [AvaloniaFact] + public async Task Dispose_LeavesNoScheduledReadToRun() + { + using var stand = await MinimapStand.WithABandAsync(); + + stand.ViewModel.Dispose(); + var advance = () => stand.Scheduler.AdvanceBy(TimeSpan.FromMinutes(2.0).Ticks); + + advance.Should().NotThrow("a read left scheduled would request into the disposed pipeline"); + } + + [AvaloniaFact] + public async Task APenChangeFollowedByAFailedRead_LeavesNoBand() + { + using var stand = await MinimapStand.WithABandAsync(); + stand.Provider.FailHistory = true; + + stand.Chart.SetActivePen(2).Should().BeTrue(); + stand.LandBandRead(); + + stand.Band.Should().BeNull("pen 1's shape must not stay drawn in pen 2's colour"); + stand.ViewModel.BandFeed.BandColor.Should().Be("#00ff00"); + } + + [AvaloniaFact] + public async Task AnAnswerQueuedBeforeAPenChange_IsDropped() + { + using var stand = new MinimapStand(); + var provider = stand.Provider; + stand.ShowPens(); + await stand.LoadExtentAsync(); + provider.BandHistoryQueries.Should().ContainSingle("pen 1's answer waits on the UI scheduler"); + provider.GatedLayer = AggregationLayer.Hour; + + stand.Chart.SetActivePen(2).Should().BeTrue(); + stand.LandBandRead(); + stand.Scheduler.AdvanceBy(TimeSpan.FromMinutes(2.0).Ticks); + + stand.Band.Should().BeNull("pen 1's answer landed after pen 2 was drawn"); + provider.BandHistoryQueries.Should().HaveCount(2); + provider.BandHistoryQueries[^1].CancellationToken.IsCancellationRequested.Should().BeFalse( + "the dropped answer schedules no read that would cut off pen 2's"); + } + + [AvaloniaFact] + public async Task AReadHeldPastTheReadDelay_IsNotCutOffByTheSchedule() + { + using var stand = await MinimapStand.WithABandAsync(); + var provider = stand.Provider; + var delay = MinimapBandFeed.NextReadDelay(stand.ViewModel.ExtentLast - stand.ViewModel.ExtentFirst); + provider.GatedLayer = AggregationLayer.Hour; + await stand.LoadExtentAsync(); + var held = provider.BandHistoryQueries.Should().HaveCount(2).And.Subject.Last(); + + stand.Scheduler.AdvanceBy(delay.Ticks); + + provider.BandHistoryQueries.Should().HaveCount(2, "the requested read replaced the scheduled one"); + held.CancellationToken.IsCancellationRequested.Should().BeFalse(); + + provider.HistoryGate.SetResult(Result.Ok>([Envelope(1)])); + stand.LandBandRead(); + stand.Scheduler.AdvanceBy(delay.Ticks - 1); + provider.BandHistoryQueries.Should().HaveCount(2); + + stand.Scheduler.AdvanceBy(1); + provider.BandHistoryQueries.Should().HaveCount(3, "the next read lands one delay after the held one"); + } + + [AvaloniaFact] + public async Task ARepeatedThrowingRead_LogsItsExceptionAtWarning() + { + var logger = new RecordingLogger(); + using var stand = new MinimapStand(logger); + var thrown = new InvalidOperationException("the band read threw"); + stand.ShowPens(); + stand.Provider.HistoryReadException = thrown; + await stand.LoadExtentAsync(); + stand.LandBandRead(); + + stand.AdvanceToNextBandRead(); + + logger.Levels.Should().Contain(LogLevel.Warning); + logger.Exceptions[logger.Levels.LastIndexOf(LogLevel.Warning)].Should().BeSameAs(thrown); + } + + [AvaloniaFact] + public async Task ASpanJustAboveTheMinuteCeiling_StaysOnTheMinuteLayer() + { + using var stand = new MinimapStand(); + stand.Provider.ArchiveFirstUtc = _extentLast - TimeSpan.FromHours(62.0); + stand.ShowPens(); + await stand.LoadExtentAsync(); + stand.LandBandRead(); + stand.Provider.BandHistoryQueries[^1].Layer.Should().Be(AggregationLayer.Minute); + + stand.Navigation.OnLiveEdge(_extentLast + TimeSpan.FromHours(2.0)); + stand.AdvanceToNextBandRead(); + + stand.Provider.BandHistoryQueries.Should().HaveCount(2); + stand.Provider.BandHistoryQueries[^1].Layer.Should().Be( + AggregationLayer.Minute, "64 h is above the 62.5 h ceiling but inside the band's hysteresis"); + } + + [Fact] + public void TheBandColumnCount_IsBelowTheChartsFloor() + { + MinimapBandFeed.MinimapColumns.Should().BeLessThan(HistoryColumnTarget.MinColumns); + } + + [AvaloniaTheory] + [InlineData(10.0 * 60.0, 2.0)] + [InlineData(60.0 * 60.0, 3.6)] + [InlineData(24.0 * 60.0 * 60.0, 60.0)] + [InlineData(365.0 * 24.0 * 60.0 * 60.0, 60.0)] + public void NextReadDelay_IsAThousandthOfTheSpanBetweenTwoSecondsAndAMinute( + double spanSeconds, + double expectedSeconds) + { + var delay = MinimapBandFeed.NextReadDelay(TimeSpan.FromSeconds(spanSeconds)); + + delay.TotalSeconds.Should().BeApproximately(expectedSeconds, 1e-9); + } + + private static PenHistoryEnvelope Envelope(int penId) + { + return new PenHistoryEnvelope(penId, [_extentFirst, _extentLast], [1.0, 2.0], [1.0, 2.0], [1.0, 2.0]); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapHoverTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapHoverTests.cs new file mode 100644 index 0000000..334fa8a --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapHoverTests.cs @@ -0,0 +1,223 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; + +using AwesomeAssertions; + +using Xunit; + +using Point = Avalonia.Point; + +namespace SemiPlot.Tests.Unit.UI.Minimap; + +[Trait("Component", "UI")] +[Trait("Area", "Bridge")] +[Trait("Category", "Unit")] +public sealed class MinimapHoverTests +{ + private const double PressFraction = 0.30; + private const double DragFraction = 0.60; + private const double HoverLineWidth = 1.0; + private const double PixelTolerance = 1.0; + private const double OffStripDistance = 6.0; + private const double FirstLabelSearchWidth = 400.0; + + [AvaloniaFact] + public async Task HoveringTheStrip_ShowsALineAndItsTimeAndLeavingHidesThem() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var strip = stand.StripCanvas; + var (hoverLine, hoverTime) = HoverControls(stand); + var pointer = stand.StripPointAt(DragFraction); + + stand.MovePointer(pointer); + + hoverLine.IsVisible.Should().BeTrue("a pointer over the strip shows its line"); + var line = stand.AreaInView(hoverLine); + line.Left.Should().BeApproximately(pointer.X, PixelTolerance, "the line sits under the pointer"); + line.Width.Should().Be(HoverLineWidth); + line.Height.Should().Be(strip.Bounds.Height, "the line crosses the whole strip"); + stand.Model.ViewModel.HoverFraction.Should().Be(pointer.X / strip.Bounds.Width); + hoverTime.IsVisible.Should().BeTrue("a pointer over the strip shows its time"); + hoverTime.Text.Should().Be(stand.Model.ViewModel.HoverLabel); + var time = stand.AreaInView(hoverTime); + time.Top.Should().BeGreaterThanOrEqualTo( + stand.AreaInView(strip).Bottom, "the time sits in the row under the strip"); + time.Center.X.Should().BeApproximately(pointer.X, PixelTolerance, "the time is centred under the line"); + EndLabelsShown(stand).Should().Equal([true, true], "a time in the middle covers neither end label"); + + stand.MovePointer(new Point(pointer.X, strip.Bounds.Height + OffStripDistance)); + + hoverLine.IsVisible.Should().BeFalse("leaving the strip hides the line"); + hoverTime.IsVisible.Should().BeFalse("leaving the strip hides the time"); + } + + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public async Task AHoverAtAnEnd_KeepsItsTimeInTheRowAndHidesTheEndLabelItCovers(bool atLeftEnd) + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var strip = stand.StripCanvas; + var (_, hoverTime) = HoverControls(stand); + var pointerX = atLeftEnd ? 0.0 : strip.Bounds.Width - 1.0; + var row = stand.AreaInView(stand.Named("LabelRow")); + + stand.MovePointer(new Point(pointerX, strip.Bounds.Height / 2.0)); + + var time = stand.AreaInView(hoverTime); + time.Left.Should().BeGreaterThanOrEqualTo(row.Left, "the time never leaves the row"); + time.Right.Should().BeLessThanOrEqualTo(row.Right, "the time never leaves the row"); + EndLabelsShown(stand).Should().Equal( + [!atLeftEnd, atLeftEnd], "the time hides the end label it covers and only that one"); + var keptLabel = stand.AreaInView(stand.Named(atLeftEnd ? "ExtentLastLabel" : "ExtentFirstLabel")); + time.Intersects(keptLabel).Should().BeFalse("the end label left shown must not be covered"); + + stand.MovePointer(new Point(pointerX, strip.Bounds.Height + OffStripDistance)); + + EndLabelsShown(stand).Should().Equal([true, true], "leaving the strip shows both end labels again"); + } + + [AvaloniaFact] + public async Task AHoverAtTheRightEdge_KeepsTheLineInsideTheStrip() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var strip = stand.StripCanvas; + var (hoverLine, _) = HoverControls(stand); + + stand.MovePointer(new Point(strip.Bounds.Width - (HoverLineWidth / 2.0), strip.Bounds.Height / 2.0)); + + hoverLine.IsVisible.Should().BeTrue(); + stand.AreaInView(hoverLine).Right.Should().BeLessThanOrEqualTo( + stand.AreaInView(strip).Right, "the line stops at the strip's right edge"); + } + + [AvaloniaFact] + public async Task AResizeDuringAHover_KeepsTheLineAtItsFraction() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var strip = stand.StripCanvas; + var (hoverLine, hoverTime) = HoverControls(stand); + stand.MovePointer(stand.StripPointAt(DragFraction)); + var fraction = stand.Model.ViewModel.HoverFraction ?? throw new InvalidOperationException("No hover shows."); + + stand.Window.Width = 600; + Dispatcher.UIThread.RunJobs(); + + strip.Bounds.Width.Should().Be(600, "the strip follows the window"); + var lineX = fraction * strip.Bounds.Width; + stand.AreaInView(hoverLine).Left.Should().BeApproximately(lineX, PixelTolerance, "the line keeps its fraction"); + stand.AreaInView(hoverTime).Center.X.Should().BeApproximately( + lineX, PixelTolerance, "the time stays centred under the line"); + } + + [AvaloniaFact] + public async Task ANewerSampleDuringAHover_RelabelsItsTime() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var strip = stand.StripCanvas; + var viewModel = stand.Model.ViewModel; + var (_, hoverTime) = HoverControls(stand); + stand.MovePointer(new Point(strip.Bounds.Width - 1.0, strip.Bounds.Height / 2.0)); + var timeBefore = hoverTime.Text; + + stand.Model.Navigation.OnLiveEdge(MinimapStand.ExtentLast + TimeSpan.FromDays(1.0)); + Dispatcher.UIThread.RunJobs(); + + hoverTime.Text.Should().NotBe(timeBefore, "a later right bound moves the time under the pointer") + .And.Be(viewModel.HoverLabel); + } + + [AvaloniaFact] + public async Task ANewFirstSampleDuringAHover_HidesTheWiderFirstLabelItNowCovers() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: false); + var strip = stand.StripCanvas; + var (_, hoverTime) = HoverControls(stand); + var narrowFirst = new DateTime(2025, 12, 9, 12, 0, 0, DateTimeKind.Utc); + var wideFirst = new DateTime(2025, 12, 10, 12, 0, 0, DateTimeKind.Utc); + await ReloadWithFirstSampleAsync(stand, wideFirst); + var (pointerX, timeLeft) = LastPointerHidingTheFirstLabel(stand); + stand.MovePointer(new Point(pointerX, strip.Bounds.Height + OffStripDistance)); + await ReloadWithFirstSampleAsync(stand, narrowFirst); + stand.MovePointer(new Point(pointerX, strip.Bounds.Height / 2.0)); + EndLabelsShown(stand)[0].Should().BeTrue("the narrow first label is clear of the time"); + + await ReloadWithFirstSampleAsync(stand, wideFirst); + + stand.AreaInView(hoverTime).Left.Should().Be(timeLeft); + EndLabelsShown(stand)[0].Should().BeFalse("the time covers the first label at its new width"); + } + + [AvaloniaFact] + public async Task ADrag_KeepsTheLineUnderThePointerUntilItLeavesTheStrip() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var (strip, window) = (stand.StripCanvas, stand.Window); + var (hoverLine, _) = HoverControls(stand); + var dragTo = stand.StripPointAt(DragFraction); + + window.MouseDown(stand.InWindow(stand.StripPointAt(PressFraction)), MouseButton.Left); + stand.MovePointer(dragTo, RawInputModifiers.LeftMouseButton); + + hoverLine.IsVisible.Should().BeTrue("the exit the capture raises does not end the hover"); + stand.AreaInView(hoverLine).Left.Should().BeApproximately( + dragTo.X, PixelTolerance, "the line follows the drag"); + + var offStrip = new Point(dragTo.X, strip.Bounds.Height + OffStripDistance); + stand.MovePointer(offStrip, RawInputModifiers.LeftMouseButton); + + hoverLine.IsVisible.Should().BeFalse("a captured drag that leaves the strip hides the line"); + + window.MouseUp(stand.InWindow(dragTo), MouseButton.Left); + } + + private static (Control Line, TextBlock Time) HoverControls(MinimapViewStand stand) + { + return (stand.Named("HoverLine"), stand.Named("HoverTimeLabel")); + } + + private static bool[] EndLabelsShown(MinimapViewStand stand) + { + return + [ + IsShown(stand.Named("ExtentFirstLabel")), + IsShown(stand.Named("ExtentLastLabel")) + ]; + } + + private static bool IsShown(Visual visual) + { + return visual.IsVisible && visual.Opacity > 0.0; + } + + private static async Task ReloadWithFirstSampleAsync(MinimapViewStand stand, DateTime firstSample) + { + stand.Model.Provider.ArchiveFirstUtc = firstSample; + await stand.Model.LoadExtentAsync(); + Dispatcher.UIThread.RunJobs(); + } + + private static (double PointerX, double TimeLeft) LastPointerHidingTheFirstLabel(MinimapViewStand stand) + { + var height = stand.StripCanvas.Bounds.Height / 2.0; + var hoverTime = stand.Named("HoverTimeLabel"); + var lastHiding = (PointerX: double.NaN, TimeLeft: double.NaN); + + for (var pointerX = 0.0; pointerX < FirstLabelSearchWidth; pointerX++) + { + stand.MovePointer(new Point(pointerX, height)); + if (EndLabelsShown(stand)[0]) + { + return lastHiding; + } + + lastHiding = (pointerX, stand.AreaInView(hoverTime).Left); + } + + throw new InvalidOperationException("No pointer position within the search clears the first label."); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapPointerInputTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapPointerInputTests.cs index 4df5487..72afa7e 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapPointerInputTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapPointerInputTests.cs @@ -1,27 +1,12 @@ -using System.Reactive.Concurrency; - -using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.XUnit; using Avalonia.Input; -using Avalonia.Interactivity; using Avalonia.Threading; -using Avalonia.VisualTree; using AwesomeAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Reactive.Testing; - -using ReactiveUI.Avalonia; - using SemiPlot.Core.Trends; -using SemiPlot.Tests.Unit.UI.Bridge; -using SemiPlot.UI.Bridge; -using SemiPlot.UI.Chart; -using SemiPlot.UI.Messages; -using SemiPlot.UI.Minimap; using Xunit; @@ -29,48 +14,41 @@ namespace SemiPlot.Tests.Unit.UI.Minimap; -// The minimap's half of the input guard: MinimapViewModelTests drive NavigateToFraction directly, so hit -// testing, capture, the drag flag and pixel-to-fraction are exercised only here, via Avalonia.Headless -// in window-client coordinates translated into StripCanvas's own space. +// The minimap's half of the input guard: MinimapViewModelTests drive NavigateToFraction directly, so hit testing, +// capture, the drag flag and pixel-to-fraction on the way to the chart's window are exercised only here. [Trait("Component", "UI")] [Trait("Area", "Bridge")] [Trait("Category", "Unit")] public sealed class MinimapPointerInputTests { - private const int WindowWidth = 900; - private const int WindowHeight = 36; private const double PressFraction = 0.30; private const double DragFraction = 0.60; private const double HoverFraction = 0.80; - private static readonly TimeSpan _batchWindow = TimeSpan.FromMilliseconds(33.0); + private const double MinimumMarkerWidth = 6.0; private static readonly TimeSpan _timeTolerance = TimeSpan.FromSeconds(1.0); - private static readonly DateTime _extentFirst = new(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc); - private static readonly DateTime _extentLast = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); [AvaloniaFact] public async Task PressThenDrag_MovesTheChartWindowToEachPointerFraction() { - var scheduler = new TestScheduler(); - var coordinator = CreateCoordinator(scheduler); - using var chartViewModel = CreateChartViewModel(coordinator, scheduler); - var navigation = chartViewModel.Navigation; - var (window, stripCanvas) = await ShowMinimapAsync(coordinator, navigation); - var pressAt = StripPointAt(stripCanvas, PressFraction); - var dragTo = StripPointAt(stripCanvas, DragFraction); + using var stand = await MinimapViewStand.ShowAsync(showPens: false); + var navigation = stand.Model.Navigation; + var window = stand.Window; + var pressAt = stand.StripPointAt(PressFraction); + var dragTo = stand.StripPointAt(DragFraction); var widthBefore = navigation.To - navigation.From; - window.MouseDown(ToWindow(stripCanvas, window, pressAt), MouseButton.Left); + window.MouseDown(stand.InWindow(pressAt), MouseButton.Left); - WindowCenter(navigation).Should().BeCloseTo( - ExpectedCenter(stripCanvas, pressAt), + stand.Model.WindowCenter.Should().BeCloseTo( + ExpectedCenter(stand, pressAt), _timeTolerance, "the press must reach the strip and recenter the chart there"); - window.MouseMove(ToWindow(stripCanvas, window, dragTo)); - window.MouseUp(ToWindow(stripCanvas, window, dragTo), MouseButton.Left); + stand.MovePointer(dragTo, RawInputModifiers.LeftMouseButton); + window.MouseUp(stand.InWindow(dragTo), MouseButton.Left); - WindowCenter(navigation).Should().BeCloseTo( - ExpectedCenter(stripCanvas, dragTo), + stand.Model.WindowCenter.Should().BeCloseTo( + ExpectedCenter(stand, dragTo), _timeTolerance, "a move while the drag holds must keep recentering the chart"); (navigation.To - navigation.From).Should().Be( @@ -80,139 +58,53 @@ public async Task PressThenDrag_MovesTheChartWindowToEachPointerFraction() [AvaloniaFact] public async Task MoveAfterRelease_LeavesTheChartWindowWhereTheDragEndedIt() { - var scheduler = new TestScheduler(); - var coordinator = CreateCoordinator(scheduler); - using var chartViewModel = CreateChartViewModel(coordinator, scheduler); - var navigation = chartViewModel.Navigation; - var (window, stripCanvas) = await ShowMinimapAsync(coordinator, navigation); - var pressAt = StripPointAt(stripCanvas, PressFraction); - var dragTo = StripPointAt(stripCanvas, DragFraction); - var hoverTo = StripPointAt(stripCanvas, HoverFraction); - - window.MouseDown(ToWindow(stripCanvas, window, pressAt), MouseButton.Left); - window.MouseMove(ToWindow(stripCanvas, window, dragTo)); - window.MouseUp(ToWindow(stripCanvas, window, dragTo), MouseButton.Left); + using var stand = await MinimapViewStand.ShowAsync(showPens: false); + var navigation = stand.Model.Navigation; + var window = stand.Window; + var pressAt = stand.StripPointAt(PressFraction); + var dragTo = stand.StripPointAt(DragFraction); + var hoverTo = stand.StripPointAt(HoverFraction); + + window.MouseDown(stand.InWindow(pressAt), MouseButton.Left); + stand.MovePointer(dragTo, RawInputModifiers.LeftMouseButton); + window.MouseUp(stand.InWindow(dragTo), MouseButton.Left); var fromAfterRelease = navigation.From; - var movesAfterRelease = MovesReachingStrip(stripCanvas); - window.MouseMove(ToWindow(stripCanvas, window, hoverTo)); + stand.MovePointer(hoverTo); - movesAfterRelease.Should().ContainSingle( - "a layer that stopped delivering moves would pass the check below without routing anything") - .Which.X.Should().BeApproximately( - hoverTo.X, 1.0, "the delivered move must be the one aimed at the hover position"); + stand.Model.ViewModel.HoverFraction.Should().Be( + hoverTo.X / stand.StripCanvas.Bounds.Width, + "a layer that stopped delivering moves would pass the check below without routing anything"); navigation.From.Should().Be( fromAfterRelease, "the release ends the drag, so a later move is a hover and navigates nothing"); - ExpectedCenter(stripCanvas, hoverTo).Should().NotBeCloseTo( - WindowCenter(navigation), + ExpectedCenter(stand, hoverTo).Should().NotBeCloseTo( + stand.Model.WindowCenter, _timeTolerance, "the hover position must differ from the drag's, or the assertion above proves nothing"); } - private static TrendCoordinator CreateCoordinator(TestScheduler scheduler) - { - var provider = new FakeDataProvider(scheduler, TimeSpan.FromMilliseconds(10.0)) - { - ArchiveFirstUtc = _extentFirst, - ArchiveLastUtc = _extentLast - }; - - return new TrendCoordinator( - provider, - provider.Pens, - scheduler, - ImmediateScheduler.Instance, - _batchWindow); - } - - // The chart view model is here for its navigation controller alone, the instance the minimap receives in - // production. - private static TrendChartViewModel CreateChartViewModel(TrendCoordinator coordinator, TestScheduler scheduler) + [AvaloniaFact] + public async Task AWindowPastTheExtent_KeepsItsMarkerInsideTheStrip() { - return new TrendChartViewModel( - coordinator, - scheduler, - AvaloniaScheduler.Instance, - new MessagePanelViewModel(), - NullLogger.Instance); - } + using var stand = await MinimapViewStand.ShowAsync(showPens: false); + var navigation = stand.Model.Navigation; + var highlight = stand.Named("WindowHighlight"); - private static async Task<(Window Window, Canvas StripCanvas)> ShowMinimapAsync( - TrendCoordinator coordinator, ChartNavigationController navigation) - { - var viewModel = new MinimapViewModel( - coordinator, - navigation, - ImmediateScheduler.Instance, - new MessagePanelViewModel(), - NullLogger.Instance); - await viewModel.LoadExtentAsync(); - viewModel.HasExtent.Should().BeTrue("without an extent the strip ignores every pointer position"); - - // Seeds the window to [last - width, last], so both drag targets sit inside the extent and neither - // pan clamps at an edge. - navigation.TrackDataExtents(_extentFirst, _extentLast); - - var view = new MinimapView - { - DataContext = viewModel - }; - var window = new Window - { - Width = WindowWidth, - Height = WindowHeight, - Content = view - }; - - window.Show(); + navigation.OnLiveEdge(MinimapStand.ExtentLast + TimeSpan.FromDays(1.0)); Dispatcher.UIThread.RunJobs(); - var stripCanvas = view.GetVisualDescendants() - .OfType() - .Single(canvas => canvas.Name == "StripCanvas"); - stripCanvas.Bounds.Width.Should().BeGreaterThan( - 0.0, "the strip divides by its own width, so a zero-width canvas navigates nowhere"); - - return (window, stripCanvas); - } - - // The witness that a move was routed at all: the strip's own handler navigates nothing once the drag is - // over, so an unchanged navigation window alone cannot tell a delivered hover from a swallowed event. - private static IReadOnlyList MovesReachingStrip(Canvas stripCanvas) - { - var moves = new List(); - - stripCanvas.AddHandler( - InputElement.PointerMovedEvent, - (_, eventArgs) => moves.Add(eventArgs.GetPosition(stripCanvas)), - RoutingStrategies.Bubble, - handledEventsToo: true); - - return moves; - } - - // Whole pixels, so the fraction the view computes back is exactly the one the expectation uses. - private static Point StripPointAt(Canvas stripCanvas, double fraction) - { - return new Point(Math.Round(fraction * stripCanvas.Bounds.Width), stripCanvas.Bounds.Height / 2.0); + navigation.From.Should().BeAfter( + MinimapStand.ExtentLast, "the sticky window must have followed the live edge past the extent"); + highlight.Width.Should().Be( + MinimumMarkerWidth, "only a floored marker at the right edge reaches past the strip unclamped"); + (Canvas.GetLeft(highlight) + highlight.Width).Should().BeLessThanOrEqualTo( + stand.StripCanvas.Bounds.Width, "the marker's right edge must stay inside the strip"); } - private static Point ToWindow(Canvas stripCanvas, Window window, Point stripPoint) - { - return stripCanvas.TranslatePoint(stripPoint, window) - ?? throw new InvalidOperationException("The strip canvas is not in the window's visual tree."); - } - - // Mirrors MinimapView.NavigateToPointer and MinimapViewModel.NavigateToFraction: the pointer's X over the - // canvas width is the fraction, and the window's center lands on that fraction of the extent. - private static DateTime ExpectedCenter(Canvas stripCanvas, Point stripPoint) + // Mirrors MinimapView.NavigateToPointer and MinimapViewModel.NavigateToFraction. + private static DateTime ExpectedCenter(MinimapViewStand stand, Point stripPoint) { return MinimapGeometry.TimeAtFraction( - _extentFirst, _extentLast, stripPoint.X / stripCanvas.Bounds.Width); - } - - private static DateTime WindowCenter(ChartNavigationController navigation) - { - return navigation.From + ((navigation.To - navigation.From) / 2.0); + MinimapStand.ExtentFirst, MinimapStand.ExtentLast, stripPoint.X / stand.StripCanvas.Bounds.Width); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapStand.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapStand.cs new file mode 100644 index 0000000..53809c5 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapStand.cs @@ -0,0 +1,104 @@ +using Avalonia.Threading; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Reactive.Testing; + +using SemiPlot.Core.Data; +using SemiPlot.Core.Trends; +using SemiPlot.Tests.Unit.UI.Bridge; +using SemiPlot.Tests.Unit.UI.Chart; +using SemiPlot.UI.Bridge; +using SemiPlot.UI.Chart; +using SemiPlot.UI.Messages; +using SemiPlot.UI.Minimap; + +namespace SemiPlot.Tests.Unit.UI.Minimap; + +/// A minimap over a seven-day archive, every scheduler one TestScheduler. +internal sealed class MinimapStand : IDisposable +{ + public static readonly DateTime ExtentFirst = new(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc); + public static readonly DateTime ExtentLast = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly TrendCoordinator _coordinator; + + public MinimapStand(ILogger? logger = null) + { + Provider = new FakeDataProvider(Scheduler, TimeSpan.FromMilliseconds(10.0)) + { + ArchiveFirstUtc = ExtentFirst, + ArchiveLastUtc = ExtentLast + }; + _coordinator = new TrendCoordinator(Provider, Provider.Pens, Scheduler, Scheduler, ChartTestBuilder.BatchWindow); + Chart = ChartTestBuilder.CreateChart(Scheduler, _coordinator, Panel); + ViewModel = new MinimapViewModel( + _coordinator, Chart, Scheduler, Panel, logger ?? NullLogger.Instance); + } + + public TestScheduler Scheduler { get; } = new(); + + public FakeDataProvider Provider { get; } + + public MessagePanelViewModel Panel { get; } = new(); + + public TrendChartViewModel Chart { get; } + + public MinimapViewModel ViewModel { get; } + + public ChartNavigationController Navigation => Chart.Navigation; + + public PenHistoryEnvelope? Band => ViewModel.BandFeed.Band; + + public DateTime WindowCenter => Navigation.From + ((Navigation.To - Navigation.From) / 2.0); + + public void Dispose() + { + ViewModel.Dispose(); + Chart.Dispose(); + _coordinator.Dispose(); + Panel.Dispose(); + } + + public static async Task WithABandAsync() + { + var stand = new MinimapStand(); + stand.ShowPens(); + await stand.LoadExtentAsync(); + stand.LandBandRead(); + stand.Band.Should().NotBeNull(); + + return stand; + } + + /// Runs the extent read through the UI scheduler's apply step. + public async Task> LoadExtentAsync() + { + var load = ViewModel.LoadExtentAsync(); + Scheduler.AdvanceBy(1); + + return await load; + } + + public void ShowPens() + { + Chart.ApplyCatalogue(Provider.Pens); + } + + /// A gated read resumes on the dispatcher; the UI scheduler applies one answer per tick. + public void LandBandRead() + { + Dispatcher.UIThread.RunJobs(); + Scheduler.AdvanceBy(1); + } + + public void AdvanceToNextBandRead() + { + Scheduler.AdvanceBy(MinimapBandFeed.NextReadDelay(ViewModel.ExtentLast - ViewModel.ExtentFirst).Ticks); + LandBandRead(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs index 4af3d9a..f64e528 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs @@ -1,19 +1,20 @@ using System.Globalization; -using System.Reactive.Concurrency; using Avalonia.Headless.XUnit; +using Avalonia.Threading; using AwesomeAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Reactive.Testing; +using FluentResults; + +using Microsoft.Extensions.Logging; + +using ReactiveUI; using SemiPlot.Core.Data; using SemiPlot.Core.Data.Errors; using SemiPlot.Core.Trends; -using SemiPlot.Tests.Unit.UI.Bridge; -using SemiPlot.UI.Bridge; -using SemiPlot.UI.Chart; +using SemiPlot.Tests.Unit.UI.Messages; using SemiPlot.UI.Messages; using SemiPlot.UI.Minimap; @@ -26,61 +27,61 @@ namespace SemiPlot.Tests.Unit.UI.Minimap; [Trait("Category", "Unit")] public sealed class MinimapViewModelTests { - private static readonly TimeSpan _batchWindow = TimeSpan.FromMilliseconds(33); - private static readonly DateTime _extentFirst = new(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc); - private static readonly DateTime _extentLast = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _extentFirst = MinimapStand.ExtentFirst; + private static readonly DateTime _extentLast = MinimapStand.ExtentLast; [AvaloniaFact] public async Task LoadExtentAsync_ExposesProviderFirstAndLast() { - var (viewModel, _, _, _) = CreateViewModel(); + using var stand = new MinimapStand(); - await viewModel.LoadExtentAsync(); + await stand.LoadExtentAsync(); - viewModel.HasExtent.Should().BeTrue(); - viewModel.ExtentFirst.Should().Be(_extentFirst); - viewModel.ExtentLast.Should().Be(_extentLast); + stand.ViewModel.HasExtent.Should().BeTrue(); + stand.ViewModel.ExtentFirst.Should().Be(_extentFirst); + stand.ViewModel.ExtentLast.Should().Be(_extentLast); } [AvaloniaFact] public async Task LoadExtentAsync_WithAnEmptyExtent_LeavesHasExtentFalse() { - var (viewModel, _, provider, _) = CreateViewModel(); - provider.ArchiveExtentOverride = ArchiveExtent.Empty; + using var stand = new MinimapStand(); + stand.Provider.ArchiveExtentOverride = ArchiveExtent.Empty; - await viewModel.LoadExtentAsync(); + await stand.LoadExtentAsync(); - viewModel.HasExtent.Should().BeFalse(); - viewModel.ExtentFirstLabel.Should().BeEmpty(); - viewModel.ExtentLastLabel.Should().BeEmpty(); + stand.ViewModel.HasExtent.Should().BeFalse(); + stand.ViewModel.ExtentFirstLabel.Should().BeEmpty(); + stand.ViewModel.ExtentLastLabel.Should().BeEmpty(); } [AvaloniaFact] public void ExtentLabels_BeforeExtentLoaded_AreEmpty() { - var (viewModel, _, _, _) = CreateViewModel(); + using var stand = new MinimapStand(); - viewModel.HasExtent.Should().BeFalse(); - viewModel.ExtentFirstLabel.Should().BeEmpty(); - viewModel.ExtentLastLabel.Should().BeEmpty(); + stand.ViewModel.HasExtent.Should().BeFalse(); + stand.ViewModel.ExtentFirstLabel.Should().BeEmpty(); + stand.ViewModel.ExtentLastLabel.Should().BeEmpty(); } [AvaloniaFact] public async Task ExtentLabels_AfterExtentLoaded_RenderLocalEndpoints() { - var (viewModel, _, _, _) = CreateViewModel(); + using var stand = new MinimapStand(); - await viewModel.LoadExtentAsync(); + await stand.LoadExtentAsync(); - viewModel.ExtentFirstLabel.Should().Be(_extentFirst.ToLocalTime().ToString("MMM d HH:mm", CultureInfo.CurrentCulture)); - viewModel.ExtentLastLabel.Should().Be(_extentLast.ToLocalTime().ToString("MMM d HH:mm", CultureInfo.CurrentCulture)); + stand.ViewModel.ExtentFirstLabel.Should().Be(Label(_extentFirst)); + stand.ViewModel.ExtentLastLabel.Should().Be(Label(_extentLast)); } [AvaloniaFact] public async Task WindowFraction_MapsTheNavigationWindowOverTheExtent() { - var (viewModel, navigation, _, _) = CreateViewModel(); - await viewModel.LoadExtentAsync(); + using var stand = new MinimapStand(); + var navigation = stand.Navigation; + await stand.LoadExtentAsync(); // Seeds the window to [last - width, last], a known sub-span of the extent. navigation.TrackDataExtents(_extentFirst, _extentLast); @@ -88,21 +89,22 @@ public async Task WindowFraction_MapsTheNavigationWindowOverTheExtent() var (start, width) = MinimapGeometry.WindowFraction( _extentFirst, _extentLast, navigation.From, navigation.To); - viewModel.WindowStartFraction.Should().BeApproximately(start, 1e-9); - viewModel.WindowWidthFraction.Should().BeApproximately(width, 1e-9); - viewModel.WindowWidthFraction.Should().BeGreaterThan(0.0); + stand.ViewModel.WindowStartFraction.Should().BeApproximately(start, 1e-9); + stand.ViewModel.WindowWidthFraction.Should().BeApproximately(width, 1e-9); + stand.ViewModel.WindowWidthFraction.Should().BeGreaterThan(0.0); } [AvaloniaFact] public async Task NavigateToFraction_RecentersTheNavigationWindowAtTheMappedTime() { - var (viewModel, navigation, _, _) = CreateViewModel(); - await viewModel.LoadExtentAsync(); + using var stand = new MinimapStand(); + var navigation = stand.Navigation; + await stand.LoadExtentAsync(); navigation.TrackDataExtents(_extentFirst, _extentLast); - viewModel.NavigateToFraction(0.5); + stand.ViewModel.NavigateToFraction(0.5); - var center = navigation.From + ((navigation.To - navigation.From) / 2.0); + var center = stand.WindowCenter; var expectedMidpoint = MinimapGeometry.TimeAtFraction(_extentFirst, _extentLast, 0.5); (center - expectedMidpoint).Duration().Should().BeLessThan(TimeSpan.FromSeconds(1.0)); } @@ -110,11 +112,12 @@ public async Task NavigateToFraction_RecentersTheNavigationWindowAtTheMappedTime [AvaloniaFact] public void NavigateToFraction_BeforeExtentLoaded_DoesNotMoveTheWindow() { - var (viewModel, navigation, _, _) = CreateViewModel(); + using var stand = new MinimapStand(); + var navigation = stand.Navigation; var fromBefore = navigation.From; var toBefore = navigation.To; - viewModel.NavigateToFraction(0.5); + stand.ViewModel.NavigateToFraction(0.5); navigation.From.Should().Be(fromBefore); navigation.To.Should().Be(toBefore); @@ -123,42 +126,208 @@ public void NavigateToFraction_BeforeExtentLoaded_DoesNotMoveTheWindow() [AvaloniaFact] public async Task AFailedExtentQueryReachesTheMessagePanel() { - var (viewModel, _, provider, panel) = CreateViewModel(); - using var messagePanel = panel; - provider.FailExtent = true; + using var stand = new MinimapStand(); + stand.Provider.FailExtent = true; - await viewModel.LoadExtentAsync(); + await stand.LoadExtentAsync(); - viewModel.HasExtent.Should().BeFalse(); - messagePanel.Entries.Should().ContainSingle() + stand.ViewModel.HasExtent.Should().BeFalse(); + stand.Panel.Entries.Should().ContainSingle() .Which.View.Should().Be(ArchiveFailureMapper.Map( new ArchiveError(ArchiveFault.ReadFailed, "bench", 5432, "semiplot_dev", "42601"))); } - private static (MinimapViewModel ViewModel, ChartNavigationController Navigation, FakeDataProvider Provider, - MessagePanelViewModel Panel) CreateViewModel() + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public async Task ANewerSampleThanTheExtent_MovesTheRightBoundAndItsLabel(bool isSticky) + { + using var stand = new MinimapStand(); + var navigation = stand.Navigation; + navigation.SeedFromArchiveExtent(new ArchiveExtent(_extentFirst, _extentLast)); + await stand.LoadExtentAsync(); + navigation.SetSticky(isSticky); + var newest = _extentLast + TimeSpan.FromMinutes(5.0); + + navigation.OnLiveEdge(newest); + + stand.ViewModel.ExtentLast.Should().Be(newest); + stand.ViewModel.ExtentLastLabel.Should().Be(Label(newest)); + var (start, width) = MinimapGeometry.WindowFraction(_extentFirst, newest, navigation.From, navigation.To); + stand.ViewModel.WindowStartFraction.Should().BeApproximately(start, 1e-9); + stand.ViewModel.WindowWidthFraction.Should().BeApproximately(width, 1e-9); + } + + [AvaloniaFact] + public async Task AnExtentEndingLaterThanTheNewestSample_KeepsItsOwnLastSample() + { + using var stand = new MinimapStand(); + var navigation = stand.Navigation; + navigation.OnLiveEdge(_extentLast - TimeSpan.FromDays(1.0)); + + await stand.LoadExtentAsync(); + navigation.OnLiveEdge(_extentLast - TimeSpan.FromHours(1.0)); + + stand.ViewModel.ExtentLast.Should().Be(_extentLast); + stand.ViewModel.ExtentLastLabel.Should().Be(Label(_extentLast)); + } + + [AvaloniaFact] + public async Task ASampleNewerThanTheExtentSeenBeforeItLands_IsTheRightBound() + { + using var stand = new MinimapStand(); + var newest = _extentLast + TimeSpan.FromMinutes(5.0); + stand.Navigation.OnLiveEdge(newest); + + await stand.LoadExtentAsync(); + + stand.ViewModel.ExtentLast.Should().Be(newest); + stand.ViewModel.ExtentLastLabel.Should().Be(Label(newest)); + } + + [AvaloniaFact] + public void ANewestSampleWithNoExtent_LeavesTheStripBlank() { - var scheduler = new TestScheduler(); - var provider = new FakeDataProvider(scheduler, TimeSpan.FromMilliseconds(10)) + using var stand = new MinimapStand(); + + stand.Navigation.OnLiveEdge(_extentLast + TimeSpan.FromMinutes(5.0)); + + stand.ViewModel.HasExtent.Should().BeFalse(); + stand.ViewModel.ExtentLast.Should().Be(default); + stand.ViewModel.ExtentFirstLabel.Should().BeEmpty(); + stand.ViewModel.ExtentLastLabel.Should().BeEmpty(); + } + + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public async Task AMissingExtentAtStart_IsReadOnceMoreOnTheNextNewSample(bool readFails) + { + using var stand = new MinimapStand(); + var provider = stand.Provider; + provider.FailExtent = readFails; + provider.ArchiveExtentOverride = readFails ? null : ArchiveExtent.Empty; + await stand.LoadExtentAsync(); + stand.ViewModel.HasExtent.Should().BeFalse(); + provider.FailExtent = false; + provider.ArchiveExtentOverride = null; + provider.GateExtent = true; + var newest = _extentLast + TimeSpan.FromMinutes(5.0); + + stand.Navigation.OnLiveEdge(newest - TimeSpan.FromMinutes(1.0)); + stand.Navigation.OnLiveEdge(newest); + + provider.ExtentQueryCount.Should().Be(2, "one read at start and one more, not one per sample"); + provider.ExtentGate.SetResult(Result.Ok(new ArchiveExtent(_extentFirst, _extentLast))); + Dispatcher.UIThread.RunJobs(); + stand.Scheduler.AdvanceBy(1); + + stand.ViewModel.HasExtent.Should().BeTrue(); + stand.ViewModel.ExtentLast.Should().Be(newest); + } + + [AvaloniaFact] + public async Task ASteadyExtentFailure_RetriesOnceAMinuteAndReportsOncePerOutage() + { + var logger = new RecordingLogger(); + using var stand = new MinimapStand(logger); + var provider = stand.Provider; + provider.FailExtent = true; + await stand.LoadExtentAsync(); + stand.Panel.Entries.Should().ContainSingle(); + var warningsAtStart = logger.Levels.Count(level => level == LogLevel.Warning); + + // The catalogue sync's own entry tops the panel, as it does during an outage, so a repeat of the + // extent's entry would not coalesce with it. + stand.Panel.Report(ArchiveFailureMapper.Map(new Error("The pen catalogue read failed."))); + for (var second = 1; second <= 170; second++) { - ArchiveFirstUtc = _extentFirst, - ArchiveLastUtc = _extentLast - }; - var coordinator = new TrendCoordinator( - provider, - provider.Pens, - scheduler, - ImmediateScheduler.Instance, - _batchWindow); - var navigation = new ChartNavigationController(); - var messagePanel = new MessagePanelViewModel(); - var viewModel = new MinimapViewModel( - coordinator, - navigation, - ImmediateScheduler.Instance, - messagePanel, - NullLogger.Instance); - - return (viewModel, navigation, provider, messagePanel); + stand.Scheduler.AdvanceBy(TimeSpan.FromSeconds(1.0).Ticks); + stand.Navigation.OnLiveEdge(_extentLast + TimeSpan.FromSeconds(second)); + } + + stand.Scheduler.AdvanceBy(1); + + provider.ExtentQueryCount.Should().Be(4, "the read at start, one on the first sample and one a minute after each"); + stand.Panel.Entries.Should().HaveCount(2, "the retries add no entry"); + logger.Levels.Count(level => level == LogLevel.Warning).Should().Be(warningsAtStart + 3); + + provider.FailExtent = false; + stand.Scheduler.AdvanceBy(TimeSpan.FromMinutes(1.0).Ticks); + stand.Navigation.OnLiveEdge(_extentLast + TimeSpan.FromMinutes(5.0)); + stand.Scheduler.AdvanceBy(1); + stand.ViewModel.HasExtent.Should().BeTrue(); + provider.FailExtent = true; + await stand.LoadExtentAsync(); + + stand.Panel.Entries.Should().HaveCount(3, "a success arms the report again"); + } + + [AvaloniaFact] + public async Task HoverAt_LabelsThePointsTimeAndClearHoverTakesItAway() + { + using var stand = new MinimapStand(); + await stand.LoadExtentAsync(); + + stand.ViewModel.HoverAt(0.25); + + stand.ViewModel.HoverFraction.Should().Be(0.25); + stand.ViewModel.HoverLabel.Should().Be(Label(MinimapGeometry.TimeAtFraction(_extentFirst, _extentLast, 0.25))); + + stand.ViewModel.ClearHover(); + + stand.ViewModel.HoverFraction.Should().BeNull(); + stand.ViewModel.HoverLabel.Should().BeEmpty(); + } + + [AvaloniaFact] + public void HoverAt_WithNoExtent_ShowsNothing() + { + using var stand = new MinimapStand(); + + stand.ViewModel.HoverAt(0.5); + + stand.ViewModel.HoverFraction.Should().BeNull(); + stand.ViewModel.HoverLabel.Should().BeEmpty(); + } + + [AvaloniaFact] + public async Task ANewerSample_RelabelsTheHoveredRightEnd() + { + using var stand = new MinimapStand(); + await stand.LoadExtentAsync(); + stand.ViewModel.HoverAt(1.0); + var labels = new List(); + using var subscription = stand.ViewModel + .WhenAnyValue(model => model.HoverLabel) + .Subscribe(labels.Add); + var newest = _extentLast + TimeSpan.FromMinutes(5.0); + + stand.Navigation.OnLiveEdge(newest); + + labels.Should().Equal(Label(_extentLast), Label(newest)); + } + + [AvaloniaFact] + public async Task ANewFirstSample_RelabelsTheHoveredLeftEnd() + { + using var stand = new MinimapStand(); + await stand.LoadExtentAsync(); + stand.ViewModel.HoverAt(0.0); + var labels = new List(); + using var subscription = stand.ViewModel + .WhenAnyValue(model => model.HoverLabel) + .Subscribe(labels.Add); + var earlier = _extentFirst - TimeSpan.FromDays(1.0); + stand.Provider.ArchiveFirstUtc = earlier; + + await stand.LoadExtentAsync(); + + labels.Should().Equal(Label(_extentFirst), Label(earlier)); + } + + private static string Label(DateTime utc) + { + return utc.ToLocalTime().ToString("MMM d HH:mm", CultureInfo.CurrentCulture); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewStand.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewStand.cs new file mode 100644 index 0000000..9a1d0a0 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewStand.cs @@ -0,0 +1,127 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Threading; + +using AwesomeAssertions; + +using SemiPlot.UI.Minimap; + +namespace SemiPlot.Tests.Unit.UI.Minimap; + +internal sealed class MinimapViewStand : IDisposable +{ + private const int WindowWidth = 900; + + private MinimapViewStand() + { + View = new MinimapView + { + DataContext = Model.ViewModel + }; + Window = new Window + { + Width = WindowWidth, + SizeToContent = SizeToContent.Height, + Content = View + }; + } + + public MinimapStand Model { get; } = new(); + + public MinimapView View { get; } + + public Window Window { get; } + + public Canvas StripCanvas => Named("StripCanvas"); + + public MinimapBand BandLayer => Named("BandLayer"); + + /// Seeds the window to [last - width, last] so a press inside the extent pans without clamping. + public static async Task ShowAsync(bool showPens) + { + var stand = new MinimapViewStand(); + var model = stand.Model; + if (showPens) + { + model.ShowPens(); + } + + await model.LoadExtentAsync(); + stand.LandBandRead(); + model.ViewModel.HasExtent.Should().BeTrue("without an extent the strip ignores every pointer position"); + + model.Navigation.TrackDataExtents(MinimapStand.ExtentFirst, MinimapStand.ExtentLast); + stand.Show(); + stand.StripCanvas.Bounds.Width.Should().BeGreaterThan( + 0.0, "the strip divides by its own width and draws no band at zero width"); + + return stand; + } + + public static MinimapViewStand ShowWithoutExtent() + { + var stand = new MinimapViewStand(); + stand.Show(); + + return stand; + } + + public T Named(string name) + where T : Control + { + return View.FindControl(name) + ?? throw new InvalidOperationException($"The minimap names no {typeof(T).Name} '{name}'."); + } + + /// Whole pixels, so the fraction the view computes back is exactly the one the expectation uses. + public Point StripPointAt(double fraction) + { + return new Point(Math.Round(fraction * StripCanvas.Bounds.Width), StripCanvas.Bounds.Height / 2.0); + } + + public Point InWindow(Point stripPoint) + { + return StripCanvas.TranslatePoint(stripPoint, Window) + ?? throw new InvalidOperationException("The strip canvas is not in the window's visual tree."); + } + + public static Rect AreaIn(Visual ancestor, Visual visual) + { + var topLeft = visual.TranslatePoint(new Point(0.0, 0.0), ancestor) + ?? throw new InvalidOperationException( + $"{visual.GetType().Name} is not in the visual tree of {ancestor.GetType().Name}."); + + return new Rect(topLeft, visual.Bounds.Size); + } + + public Rect AreaInView(Visual visual) + { + return AreaIn(View, visual); + } + + public void MovePointer(Point stripPoint, RawInputModifiers modifiers = RawInputModifiers.None) + { + Window.MouseMove(InWindow(stripPoint), modifiers); + Dispatcher.UIThread.RunJobs(); + } + + public void LandBandRead() + { + Model.LandBandRead(); + Dispatcher.UIThread.RunJobs(); + } + + public void Dispose() + { + Window.Close(); + Model.Dispose(); + } + + private void Show() + { + Window.Show(); + Dispatcher.UIThread.RunJobs(); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs new file mode 100644 index 0000000..7401b8d --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs @@ -0,0 +1,131 @@ +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Threading; + +using AwesomeAssertions; + +using SemiPlot.Core.Trends; +using SemiPlot.UI.Minimap; + +using Xunit; + +namespace SemiPlot.Tests.Unit.UI.Minimap; + +[Trait("Component", "UI")] +[Trait("Area", "Bridge")] +[Trait("Category", "Unit")] +public sealed class MinimapViewTests +{ + private const double LabelEdgeSlack = 8.0; + + [AvaloniaFact] + public async Task TheStrip_HoldsOneBandBelowTheMarker() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var children = stand.StripCanvas.Children; + + var bandLayer = children.OfType().Should().ContainSingle().Subject; + + children.IndexOf(bandLayer).Should().BeLessThan(children.IndexOf(stand.Named("WindowHighlight"))); + bandLayer.IsHitTestVisible.Should().BeFalse("the strip under the band takes the pointer"); + } + + [AvaloniaFact] + public async Task TheStrip_DrawsNoBaselineAndLabelsItsEndsUnderTheStrip() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var view = stand.View; + var strip = stand.AreaInView(stand.StripCanvas); + var first = stand.AreaInView(stand.Named("ExtentFirstLabel")); + var last = stand.AreaInView(stand.Named("ExtentLastLabel")); + + stand.StripCanvas.Children.Select(child => child.Name).Should().Equal( + ["BandLayer", "WindowHighlight", "HoverLine"], + "the strip draws the band, the marker over it and the hover line over both, and no line of its own"); + foreach (var label in new[] { first, last }) + { + label.Width.Should().BePositive("each end label carries its time"); + label.Top.Should().BeGreaterThanOrEqualTo(strip.Bottom, "the labels sit under the strip, not on it"); + label.Bottom.Should().BeLessThanOrEqualTo(view.Bounds.Height, "the view must show the whole label"); + } + + first.Left.Should().BeInRange(0.0, LabelEdgeSlack, "the first sample's label sits at the left edge"); + last.Right.Should().BeInRange( + view.Bounds.Width - LabelEdgeSlack, view.Bounds.Width, "the last sample's label sits at the right edge"); + first.Right.Should().BeLessThan(last.Left, "the two end labels must not overlap"); + } + + [AvaloniaFact] + public async Task TheView_KeepsItsHeightWhenTheExtentFillsItsLabels() + { + using var stand = MinimapViewStand.ShowWithoutExtent(); + var lastLabel = stand.Named("ExtentLastLabel"); + lastLabel.Text.Should().BeEmpty("no extent is read yet"); + var emptyHeight = stand.View.Bounds.Height; + + await stand.Model.LoadExtentAsync(); + Dispatcher.UIThread.RunJobs(); + + lastLabel.Text.Should().NotBeEmpty("the extent landed"); + stand.View.Bounds.Height.Should().Be(emptyHeight, "the minimap row never jumps when the labels fill"); + } + + [AvaloniaFact] + public async Task TheBand_CoversTheStrip() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + + stand.BandLayer.Bounds.Size.Should().Be(stand.StripCanvas.Bounds.Size); + } + + [AvaloniaFact] + public async Task APublishedBand_GivesTheControlFiguresInThePenColour() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var model = stand.Model; + model.Band.Should().NotBeNull(); + + stand.BandLayer.Figures.Should().NotBeEmpty(); + stand.BandLayer.Figures.Should().BeEquivalentTo( + MinimapGeometry.BandFigures( + model.Band!, + model.ViewModel.ExtentFirst, + model.ViewModel.ExtentLast, + stand.StripCanvas.Bounds.Width, + stand.StripCanvas.Bounds.Height), + options => options.WithStrictOrdering()); + stand.BandLayer.BandColor.Should().BeOfType() + .Which.Color.Should().Be(Color.Parse(model.ViewModel.BandFeed.BandColor!)); + } + + [AvaloniaFact] + public async Task APan_LeavesTheBandFiguresAsTheyWere() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var viewModel = stand.Model.ViewModel; + var figures = stand.BandLayer.Figures; + var startBefore = viewModel.WindowStartFraction; + + stand.Model.Navigation.PanBy(TimeSpan.FromHours(-12.0)); + + viewModel.WindowStartFraction.Should().NotBe(startBefore, "the pan must move the marker"); + stand.BandLayer.Figures.Should().BeSameAs(figures, "only the bounds, the band and the size reshape the band"); + } + + [AvaloniaFact] + public async Task HidingTheDrawnPen_TakesTheFiguresAway() + { + using var stand = await MinimapViewStand.ShowAsync(showPens: true); + var chart = stand.Model.Chart; + stand.BandLayer.Figures.Should().NotBeEmpty(); + + chart.SetPenVisibility(1, isVisible: false); + stand.LandBandRead(); + chart.SetPenVisibility(2, isVisible: false); + stand.LandBandRead(); + + stand.Model.Band.Should().BeNull(); + stand.BandLayer.Figures.Should().BeEmpty(); + } +} diff --git a/SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs b/SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs index 92fe519..cf92023 100644 --- a/SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs +++ b/SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs @@ -36,12 +36,21 @@ public ChartNavigationController() public AggregationLayer ActiveLayer { get; private set; } + /// The newest sample the chart has seen, from the archive or the live edge; null until one arrives. + public DateTime? NewestSample { get; private set; } + // Number of pixel columns the canvas will draw. The stored value is quantized, so it does not read back // as the value passed to SetTargetColumnCount. public int TargetColumnCount { get; private set; } = HistoryColumnTarget.MaxColumns; public event EventHandler? WindowChanged; + /// + /// Raised on every write of , sticky or not; when the window also moves, after the + /// move and ahead of . + /// + public event EventHandler? NewestSampleMoved; + public void SetTargetColumnCount(int columns) { var quantized = QuantizeColumnCount(columns); @@ -99,6 +108,7 @@ public void TrackDataExtents(DateTime firstSample, DateTime lastSample) // The first snap must NOT re-query (would load history twice at startup); later navigation gestures // go through ApplyWindowChange and do re-query. ActiveLayer = LayerForCurrentWidth(); + RecordNewestSample(lastSample); WindowChanged?.Invoke( this, new NavigationWindow(_navigation.From, _navigation.To, ActiveLayer, RequiresHistoryRequery: false)); @@ -145,17 +155,31 @@ public void OnLiveEdge(DateTime now) if (!_navigation.IsSticky) { + RecordNewestSample(now); + return; } _navigation.OnLiveEdge(_liveEdge); ActiveLayer = LayerForCurrentWidth(); + RecordNewestSample(now); WindowChanged?.Invoke( this, new NavigationWindow(_navigation.From, _navigation.To, ActiveLayer, RequiresHistoryRequery: false)); } + private void RecordNewestSample(DateTime sample) + { + if (NewestSample >= sample) + { + return; + } + + NewestSample = sample; + NewestSampleMoved?.Invoke(this, sample); + } + private void ApplyWindowChange() { ActiveLayer = LayerForCurrentWidth(); diff --git a/SemiPlot/SemiPlot.UI/MainWindow/MainWindow.axaml b/SemiPlot/SemiPlot.UI/MainWindow/MainWindow.axaml index 117b7b7..8017b3e 100644 --- a/SemiPlot/SemiPlot.UI/MainWindow/MainWindow.axaml +++ b/SemiPlot/SemiPlot.UI/MainWindow/MainWindow.axaml @@ -76,7 +76,6 @@ Name="MinimapRow" IsVisible="{Binding IsMinimapVisible}"> diff --git a/SemiPlot/SemiPlot.UI/MainWindow/TrendWindow.cs b/SemiPlot/SemiPlot.UI/MainWindow/TrendWindow.cs index 42c995a..f41c1be 100644 --- a/SemiPlot/SemiPlot.UI/MainWindow/TrendWindow.cs +++ b/SemiPlot/SemiPlot.UI/MainWindow/TrendWindow.cs @@ -88,7 +88,7 @@ public static TrendWindow Build( loggerFactory.CreateLogger()); var minimapLogger = loggerFactory.CreateLogger(); - var minimap = new MinimapViewModel(coordinator, chart.Navigation, uiScheduler, messagePanel, minimapLogger); + var minimap = new MinimapViewModel(coordinator, chart, uiScheduler, messagePanel, minimapLogger); var catalogueSync = new PenCatalogueSync( dataProvider, startupData.Pens, diff --git a/SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs b/SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs new file mode 100644 index 0000000..7704c9e --- /dev/null +++ b/SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs @@ -0,0 +1,89 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +using SemiPlot.Core.Trends; + +using Pen = Avalonia.Media.Pen; + +namespace SemiPlot.UI.Minimap; + +public sealed class MinimapBand : Control +{ + public static readonly StyledProperty> FiguresProperty = + AvaloniaProperty.Register>(nameof(Figures), []); + + public static readonly StyledProperty BandColorProperty = + AvaloniaProperty.Register(nameof(BandColor)); + + private const double FillOpacity = 0.35; + private const double CenterLineThickness = 1.0; + + static MinimapBand() + { + AffectsRender(FiguresProperty, BandColorProperty); + } + + public IReadOnlyList Figures + { + get => GetValue(FiguresProperty); + set => SetValue(FiguresProperty, value); + } + + public IBrush? BandColor + { + get => GetValue(BandColorProperty); + set => SetValue(BandColorProperty, value); + } + + public override void Render(DrawingContext context) + { + var brush = BandColor; + var figures = Figures; + if (brush is null || figures.Count == 0) + { + return; + } + + using (context.PushOpacity(FillOpacity)) + { + context.DrawGeometry(brush, null, Polylines(figures, figure => figure.Outline, isClosed: true)); + } + + context.DrawGeometry( + null, + new Pen(brush, CenterLineThickness), + Polylines(figures, figure => figure.CenterLine, isClosed: false)); + } + + private static StreamGeometry Polylines( + IReadOnlyList figures, + Func> pointsOf, + bool isClosed) + { + var geometry = new StreamGeometry(); + using var geometryContext = geometry.Open(); + foreach (var figure in figures) + { + AddPolyline(geometryContext, pointsOf(figure), isClosed); + } + + return geometry; + } + + private static void AddPolyline(StreamGeometryContext context, IReadOnlyList points, bool isClosed) + { + context.BeginFigure(ToPoint(points[0]), isFilled: isClosed); + for (var index = 1; index < points.Count; index++) + { + context.LineTo(ToPoint(points[index])); + } + + context.EndFigure(isClosed); + } + + private static Point ToPoint(BandPoint point) + { + return new Point(point.X, point.Y); + } +} diff --git a/SemiPlot/SemiPlot.UI/Minimap/MinimapBandFeed.cs b/SemiPlot/SemiPlot.UI/Minimap/MinimapBandFeed.cs new file mode 100644 index 0000000..e8cec0f --- /dev/null +++ b/SemiPlot/SemiPlot.UI/Minimap/MinimapBandFeed.cs @@ -0,0 +1,233 @@ +using System.Diagnostics; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using FluentResults; + +using Microsoft.Extensions.Logging; + +using ReactiveUI; + +using SemiPlot.Core.Trends; +using SemiPlot.UI.Bridge; +using SemiPlot.UI.Chart; +using SemiPlot.UI.Messages; + +namespace SemiPlot.UI.Minimap; + +/// +/// Reads the drawn pen's overview over the strip's bounds and publishes it with the pen's colour +/// (docs/architecture/trend-interaction.md#archive-overview-minimap). +/// +public sealed class MinimapBandFeed : ReactiveObject, IDisposable +{ + /// Below HistoryColumnTarget.MinColumns, so no chart read asks for it. + internal const int MinimapColumns = 250; + + private const double ReadDelayDivisor = 1000.0; + private static readonly TimeSpan _fastestRead = TimeSpan.FromSeconds(2.0); + private static readonly TimeSpan _slowestRead = TimeSpan.FromSeconds(60.0); + + private readonly TrendCoordinator _coordinator; + private readonly TrendChartViewModel _chart; + private readonly Func<(DateTime First, DateTime Last)?> _readBounds; + private readonly IScheduler _uiScheduler; + private readonly MessagePanelViewModel _messagePanel; + private readonly ILogger _logger; + private readonly OutageReport _outage; + private readonly CompositeDisposable _disposables = []; + private readonly Subject _requests = new(); + private readonly SerialDisposable _nextRead = new(); + private readonly ObservableAsPropertyHelper _bandColor; + + private BandRequest? _latestRequest; + private AggregationLayer _layer; + + internal MinimapBandFeed( + TrendCoordinator coordinator, + TrendChartViewModel chart, + Func<(DateTime First, DateTime Last)?> readBounds, + IScheduler uiScheduler, + MessagePanelViewModel messagePanel, + ILogger logger) + { + _coordinator = coordinator; + _chart = chart; + _readBounds = readBounds; + _uiScheduler = uiScheduler; + _messagePanel = messagePanel; + _logger = logger; + _outage = new OutageReport("minimap band read", messagePanel, logger); + + _disposables.Add(SubscribeToReads()); + _disposables.Add(_nextRead); + _disposables.Add(_requests); + _disposables.Add(ReadOnDrawnPenChange()); + _disposables.Add(DrawnPenColor().ToProperty(this, feed => feed.BandColor, out _bandColor)); + } + + /// The drawn pen's overview over the strip; null with no drawn pen, no bounds or no rows. + public PenHistoryEnvelope? Band + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } + + /// The drawn pen's stored colour, followed live; null with no drawn pen. + public string? BandColor => _bandColor.Value; + + public void Dispose() + { + _disposables.Dispose(); + } + + /// Reads the band now, in place of the read in flight and the scheduled one. + public void RequestRead() + { + var request = NextRequest(); + if (request?.PenId != Band?.PenId) + { + Band = null; + } + + _latestRequest = request; + _nextRead.Disposable = null; + _requests.OnNext(request); + } + + private void TryRequestRead() + { + try + { + RequestRead(); + } + catch (Exception requestFailure) + { + ReportFailure(requestFailure); + } + } + + private IDisposable SubscribeToReads() + { + return _requests + .Select(Read) + .Switch() + .ObserveOn(_uiScheduler) + .Subscribe(ApplyRead, ReportFailure); + } + + private IDisposable ReadOnDrawnPenChange() + { + return _chart + .WhenAnyValue(chart => chart.DrawnPenId) + .Skip(1) + .Subscribe(_ => TryRequestRead(), ReportFailure); + } + + private IObservable DrawnPenColor() + { + return _chart + .WhenAnyValue(chart => chart.DrawnPenId, chart => chart.Pens, (penId, _) => penId) + .Select(penId => penId is { } drawnPenId ? _chart.FindPen(drawnPenId) : null) + .Select(state => state?.WhenAnyValue(drawn => drawn.Pen.Color) ?? Observable.Return(null)) + .Switch() + .DistinctUntilChanged(); + } + + private BandRequest? NextRequest() + { + if (_readBounds() is not { } bounds || _chart.DrawnPenId is not { } penId) + { + return null; + } + + _layer = ChartNavigationController.LayerForWidth(bounds.Last - bounds.First, _layer, MinimapColumns); + + return new BandRequest(penId, bounds.First, bounds.Last, _layer); + } + + private IObservable Read(BandRequest? request) + { + return request is null + ? Observable.Empty() + : Observable.Create(async (observer, cancellation) => + observer.OnNext(new LandedRead(request, await ReadAsync(request, cancellation)))); + } + + /// Never throws: a thrown read comes back as a failed result, so it cannot end the read pipeline. + private async Task> ReadAsync(BandRequest request, CancellationToken cancellation) + { + var started = Stopwatch.GetTimestamp(); + Result result; + + try + { + var read = await _coordinator.QueryHistoryAsync( + [request.PenId], request.FromUtc, request.ToUtc, request.Layer, MinimapColumns, cancellation); + result = read.Map(envelopes => envelopes.FirstOrDefault(envelope => envelope.PenId == request.PenId)); + } + catch (Exception readFailure) + { + result = Result.Fail(new ExceptionalError(readFailure)); + } + + _logger.LogDebug( + "The minimap band read {Layer} at {Columns} columns in {Elapsed}", + request.Layer, + MinimapColumns, + Stopwatch.GetElapsedTime(started)); + + return result; + } + + private void ApplyRead(LandedRead read) + { + var isSuperseded = !ReferenceEquals(read.Request, _latestRequest); + if (isSuperseded) + { + return; + } + + try + { + var span = read.Request.ToUtc - read.Request.FromUtc; + _nextRead.Disposable = _uiScheduler.Schedule(NextReadDelay(span), TryRequestRead); + ApplyResult(read.Result); + } + catch (Exception applyFailure) + { + ReportFailure(applyFailure); + } + } + + internal static TimeSpan NextReadDelay(TimeSpan span) + { + var delay = span / ReadDelayDivisor; + + return TimeSpan.FromTicks(Math.Clamp(delay.Ticks, _fastestRead.Ticks, _slowestRead.Ticks)); + } + + private void ApplyResult(Result result) + { + if (result.IsFailed) + { + _outage.Failed(result.Errors); + + return; + } + + _outage.Succeeded(); + Band = result.Value; + } + + private void ReportFailure(Exception failure) + { + _messagePanel.TryReportFailure(new ExceptionalError(failure), _logger); + } + + private sealed record BandRequest(int PenId, DateTime FromUtc, DateTime ToUtc, AggregationLayer Layer); + + private sealed record LandedRead(BandRequest Request, Result Result); +} diff --git a/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml b/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml index 4178825..1af5907 100644 --- a/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml +++ b/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml @@ -3,41 +3,62 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:minimap="clr-namespace:SemiPlot.UI.Minimap" + xmlns:legend="clr-namespace:SemiPlot.UI.Legend" mc:Ignorable="d" d:DesignWidth="900" - d:DesignHeight="36" + d:DesignHeight="51" x:Class="SemiPlot.UI.Minimap.MinimapView" x:DataType="minimap:MinimapViewModel"> - - - - - - + + + + + + + + + + + + + + + + + diff --git a/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs b/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs index 5a5eac9..31bfa91 100644 --- a/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs +++ b/SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs @@ -6,13 +6,15 @@ using ReactiveUI; +using SemiPlot.Core.Trends; + namespace SemiPlot.UI.Minimap; public partial class MinimapView : UserControl { // Floors a sub-pixel window fraction so the marker stays visible. private const double MinimumHighlightWidth = 6.0; - private const double LabelEdgePadding = 4.0; + private const double EndLabelCoverDistance = 6.0; private readonly CompositeDisposable _disposables = []; private bool _isDragging; @@ -25,6 +27,7 @@ public MinimapView() StripCanvas.PointerMoved += OnPointerMoved; StripCanvas.PointerReleased += OnPointerReleased; StripCanvas.PointerCaptureLost += OnPointerCaptureLost; + StripCanvas.PointerExited += OnPointerExited; this.GetObservable(BoundsProperty).Subscribe(_ => UpdateStrip()); @@ -42,24 +45,47 @@ private void OnDataContextChanged(object? sender, EventArgs e) _disposables.Add(viewModel .WhenAnyValue( - model => model.WindowStartFraction, - model => model.WindowWidthFraction, - model => model.HasExtent) - .Subscribe(_ => UpdateStrip())); + model => model.HasExtent, + model => model.ExtentFirst, + model => model.ExtentLast, + model => model.BandFeed.Band) + .Subscribe(_ => LayoutBand())); + _disposables.Add(viewModel + .WhenAnyValue(model => model.WindowStartFraction, model => model.WindowWidthFraction, model => model.HasExtent) + .Subscribe(_ => PlaceMarker())); + _disposables.Add(viewModel + .WhenAnyValue(model => model.HoverFraction, model => model.HoverLabel) + .Subscribe(_ => PlaceHover())); } private void UpdateStrip() + { + LayoutBand(); + PlaceMarker(); + PlaceHover(); + } + + private void LayoutBand() { if (DataContext is not MinimapViewModel viewModel) { return; } - var width = StripCanvas.Bounds.Width; - var height = StripCanvas.Bounds.Height; + var (width, height) = (StripCanvas.Bounds.Width, StripCanvas.Bounds.Height); + BandLayer.Width = width; + BandLayer.Height = height; + BandLayer.Figures = viewModel is { HasExtent: true, BandFeed.Band: { } band } + ? MinimapGeometry.BandFigures(band, viewModel.ExtentFirst, viewModel.ExtentLast, width, height) + : []; + } - LayoutBaseline(width, height); - LayoutEndLabel(width, height); + private void PlaceMarker() + { + if (DataContext is not MinimapViewModel viewModel) + { + return; + } if (!viewModel.HasExtent) { @@ -68,22 +94,69 @@ private void UpdateStrip() return; } + var stripWidth = StripCanvas.Bounds.Width; + var (left, width) = MinimapGeometry.MarkerSpan( + viewModel.WindowStartFraction, viewModel.WindowWidthFraction, stripWidth, MinimumHighlightWidth); WindowHighlight.IsVisible = true; - Canvas.SetLeft(WindowHighlight, viewModel.WindowStartFraction * width); - WindowHighlight.Width = Math.Max(MinimumHighlightWidth, viewModel.WindowWidthFraction * width); - WindowHighlight.Height = height; + Canvas.SetLeft(WindowHighlight, left); + WindowHighlight.Width = width; + WindowHighlight.Height = StripCanvas.Bounds.Height; + } + + private void PlaceHover() + { + if (DataContext is not MinimapViewModel viewModel) + { + return; + } + + if (viewModel.HoverFraction is not { } fraction) + { + HideHover(); + + return; + } + + var stripWidth = StripCanvas.Bounds.Width; + var pointerX = fraction * stripWidth; + HoverLine.IsVisible = true; + Canvas.SetLeft(HoverLine, MinimapGeometry.SpanLeftWithin(pointerX, HoverLine.Width, stripWidth)); + HoverLine.Height = StripCanvas.Bounds.Height; + + PlaceHoverTime(viewModel.HoverLabel, pointerX + StripCanvas.Bounds.X - LabelRow.Bounds.X); } - private void LayoutBaseline(double width, double height) + // docs/architecture/trend-interaction.md#archive-overview-minimap + private void PlaceHoverTime(string text, double rowPointerX) { - Baseline.Width = width; - Canvas.SetTop(Baseline, height / 2.0); + HoverTimeLabel.Text = text; + HoverTimeLabel.IsVisible = true; + + var placement = MinimapGeometry.PlaceHoverTime( + rowPointerX, + MeasuredWidth(HoverTimeLabel), + LabelRow.Bounds.Width, + MeasuredWidth(ExtentFirstLabel), + MeasuredWidth(ExtentLastLabel), + EndLabelCoverDistance); + Canvas.SetLeft(HoverTimeLabel, placement.Left); + ExtentFirstLabel.Opacity = placement.CoversFirst ? 0.0 : 1.0; + ExtentLastLabel.Opacity = placement.CoversLast ? 0.0 : 1.0; } - private void LayoutEndLabel(double width, double height) + private static double MeasuredWidth(Control control) { - ExtentLastLabel.Measure(new Size(width, height)); - Canvas.SetLeft(ExtentLastLabel, width - ExtentLastLabel.DesiredSize.Width - LabelEdgePadding); + control.Measure(Size.Infinity); + + return control.DesiredSize.Width; + } + + private void HideHover() + { + HoverLine.IsVisible = false; + HoverTimeLabel.IsVisible = false; + ExtentFirstLabel.Opacity = 1.0; + ExtentLastLabel.Opacity = 1.0; } private void OnPointerPressed(object? sender, PointerPressedEventArgs args) @@ -105,6 +178,8 @@ private void OnPointerMoved(object? sender, PointerEventArgs args) { NavigateToPointer(args); } + + HoverAtPointer(args); } private void OnPointerReleased(object? sender, PointerReleasedEventArgs args) @@ -124,20 +199,45 @@ private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs ar _isDragging = false; } - private void NavigateToPointer(PointerEventArgs args) + // docs/architecture/trend-interaction.md#archive-overview-minimap + private void OnPointerExited(object? sender, PointerEventArgs args) + { + if (!_isDragging && DataContext is MinimapViewModel viewModel) + { + viewModel.ClearHover(); + } + } + + private void HoverAtPointer(PointerEventArgs args) { if (DataContext is not MinimapViewModel viewModel) { return; } - var width = StripCanvas.Bounds.Width; - if (width <= 0.0) + var position = args.GetPosition(StripCanvas); + if (new Rect(StripCanvas.Bounds.Size).Contains(position) && StripFraction(position) is { } fraction) { - return; + viewModel.HoverAt(fraction); + } + else + { + viewModel.ClearHover(); } + } + + private void NavigateToPointer(PointerEventArgs args) + { + if (DataContext is MinimapViewModel viewModel && StripFraction(args.GetPosition(StripCanvas)) is { } fraction) + { + viewModel.NavigateToFraction(fraction); + } + } + + private double? StripFraction(Point position) + { + var width = StripCanvas.Bounds.Width; - var fraction = args.GetPosition(StripCanvas).X / width; - viewModel.NavigateToFraction(fraction); + return width > 0.0 ? position.X / width : null; } } diff --git a/SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs b/SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs index 573ffda..194e210 100644 --- a/SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs +++ b/SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs @@ -19,41 +19,78 @@ namespace SemiPlot.UI.Minimap; public sealed class MinimapViewModel : ReactiveObject, IDisposable { + private static readonly TimeSpan _missingExtentRetryInterval = TimeSpan.FromSeconds(60.0); + private readonly TrendCoordinator _coordinator; - private readonly CompositeDisposable _disposables = []; - private readonly ILogger _logger; - private readonly MessagePanelViewModel _messagePanel; - private readonly ChartNavigationController _navigation; + private readonly TrendChartViewModel _chart; private readonly IScheduler _uiScheduler; + private readonly MessagePanelViewModel _messagePanel; + private readonly ILogger _logger; + private readonly OutageReport _extentOutage; + private readonly CompositeDisposable _disposables = []; + private readonly SerialDisposable _missingExtentRead = new(); + private bool _isExtentMissing; + private DateTimeOffset _nextMissingExtentRead = DateTimeOffset.MinValue; private bool _isDisposed; public MinimapViewModel( TrendCoordinator coordinator, - ChartNavigationController navigation, + TrendChartViewModel chart, IScheduler uiScheduler, MessagePanelViewModel messagePanel, ILogger logger) { _coordinator = coordinator; - _navigation = navigation; + _chart = chart; _uiScheduler = uiScheduler; _messagePanel = messagePanel; _logger = logger; + _extentOutage = new OutageReport("archive extent read", messagePanel, logger); + BandFeed = new MinimapBandFeed(coordinator, chart, ReadBounds, uiScheduler, messagePanel, logger); - _navigation.WindowChanged += OnNavigationWindowChanged; - _disposables.Add(Disposable.Create(() => _navigation.WindowChanged -= OnNavigationWindowChanged)); + _disposables.Add(FollowNavigation()); + _disposables.Add(_missingExtentRead); + _disposables.Add(BandFeed); } - public DateTime ExtentFirst { get; private set; } + public DateTime ExtentFirst + { + get; + private set + { + this.RaiseAndSetIfChanged(ref field, value); + this.RaisePropertyChanged(nameof(ExtentFirstLabel)); + this.RaisePropertyChanged(nameof(HoverLabel)); + } + } - public DateTime ExtentLast { get; private set; } + /// The strip's right bound: the later of the extent's last sample and the chart's newest sample. + public DateTime ExtentLast + { + get; + private set + { + this.RaiseAndSetIfChanged(ref field, value); + this.RaisePropertyChanged(nameof(ExtentLastLabel)); + this.RaisePropertyChanged(nameof(HoverLabel)); + } + } - public bool HasExtent { get; private set; } + public bool HasExtent + { + get; + private set + { + this.RaiseAndSetIfChanged(ref field, value); + this.RaisePropertyChanged(nameof(ExtentFirstLabel)); + this.RaisePropertyChanged(nameof(ExtentLastLabel)); + } + } - public string ExtentFirstLabel => HasExtent ? FormatEndpoint(ExtentFirst) : string.Empty; + public string ExtentFirstLabel => HasExtent ? FormatTime(ExtentFirst) : string.Empty; - public string ExtentLastLabel => HasExtent ? FormatEndpoint(ExtentLast) : string.Empty; + public string ExtentLastLabel => HasExtent ? FormatTime(ExtentLast) : string.Empty; public double WindowStartFraction { @@ -67,6 +104,22 @@ public double WindowWidthFraction private set => this.RaiseAndSetIfChanged(ref field, value); } = 1.0; + public double? HoverFraction + { + get; + private set + { + this.RaiseAndSetIfChanged(ref field, value); + this.RaisePropertyChanged(nameof(HoverLabel)); + } + } + + public string HoverLabel => HoverFraction is { } fraction + ? FormatTime(MinimapGeometry.TimeAtFraction(ExtentFirst, ExtentLast, fraction)) + : string.Empty; + + public MinimapBandFeed BandFeed { get; } + public void Dispose() { if (_isDisposed) @@ -99,8 +152,42 @@ public void NavigateToFraction(double fraction) } var target = MinimapGeometry.TimeAtFraction(ExtentFirst, ExtentLast, fraction); - var currentCenter = _navigation.From + ((_navigation.To - _navigation.From) / 2.0); - _navigation.PanBy(target - currentCenter); + var navigation = _chart.Navigation; + var currentCenter = navigation.From + ((navigation.To - navigation.From) / 2.0); + navigation.PanBy(target - currentCenter); + } + + public void HoverAt(double fraction) + { + if (!HasExtent) + { + return; + } + + HoverFraction = fraction; + } + + public void ClearHover() + { + HoverFraction = null; + } + + private IDisposable FollowNavigation() + { + var navigation = _chart.Navigation; + navigation.WindowChanged += OnNavigationWindowChanged; + navigation.NewestSampleMoved += OnNewestSampleMoved; + + return Disposable.Create(() => + { + navigation.WindowChanged -= OnNavigationWindowChanged; + navigation.NewestSampleMoved -= OnNewestSampleMoved; + }); + } + + private (DateTime First, DateTime Last)? ReadBounds() + { + return HasExtent ? (ExtentFirst, ExtentLast) : null; } private void ApplyExtent(Result result) @@ -110,13 +197,17 @@ private void ApplyExtent(Result result) return; } + _isExtentMissing = !HasExtent && (result.IsFailed || result.Value.IsEmpty); + if (result.IsFailed) { - _messagePanel.ReportFailure(result, _logger); + _extentOutage.Failed(result.Errors); return; } + _extentOutage.Succeeded(); + // An empty extent is a normal state of a fresh archive: leave HasExtent false so the strip stays // blank. if (result.Value.IsEmpty) @@ -124,18 +215,42 @@ private void ApplyExtent(Result result) return; } + var navigation = _chart.Navigation; ExtentFirst = result.Value.FirstUtc; - ExtentLast = result.Value.LastUtc; + ExtentLast = MinimapGeometry.RightBound(result.Value.LastUtc, navigation.NewestSample); HasExtent = true; - this.RaisePropertyChanged(nameof(ExtentFirst)); - this.RaisePropertyChanged(nameof(ExtentLast)); - this.RaisePropertyChanged(nameof(HasExtent)); - this.RaisePropertyChanged(nameof(ExtentFirstLabel)); - this.RaisePropertyChanged(nameof(ExtentLastLabel)); - RefreshWindowFraction(_navigation.From, _navigation.To); + RefreshWindowFraction(navigation.From, navigation.To); + BandFeed.RequestRead(); + } + + private void ReadMissingExtent() + { + _isExtentMissing = false; + _nextMissingExtentRead = _uiScheduler.Now + _missingExtentRetryInterval; + _missingExtentRead.Disposable = Observable + .Create>(async observer => observer.OnNext(await _coordinator.QueryArchiveExtentAsync())) + .ObserveOn(_uiScheduler) + .Subscribe(TryApplyExtent, ReportFailure); + } + + private void TryApplyExtent(Result result) + { + try + { + ApplyExtent(result); + } + catch (Exception applyFailure) + { + ReportFailure(applyFailure); + } + } + + private void ReportFailure(Exception failure) + { + _messagePanel.TryReportFailure(new ExceptionalError(failure), _logger); } - private static string FormatEndpoint(DateTime utc) + private static string FormatTime(DateTime utc) { return utc.ToLocalTime().ToString("MMM d HH:mm", CultureInfo.CurrentCulture); } @@ -145,6 +260,29 @@ private void OnNavigationWindowChanged(object? sender, NavigationWindow window) RefreshWindowFraction(window.From, window.To); } + private void OnNewestSampleMoved(object? sender, DateTime newestSample) + { + if (!HasExtent) + { + var isRetryDue = _uiScheduler.Now >= _nextMissingExtentRead; + if (_isExtentMissing && isRetryDue) + { + ReadMissingExtent(); + } + + return; + } + + var rightBound = MinimapGeometry.RightBound(ExtentLast, newestSample); + if (rightBound == ExtentLast) + { + return; + } + + ExtentLast = rightBound; + RefreshWindowFraction(_chart.Navigation.From, _chart.Navigation.To); + } + private void RefreshWindowFraction(DateTime from, DateTime to) { if (!HasExtent) diff --git a/SemiPlot/SemiPlot.UI/Minimap/OutageReport.cs b/SemiPlot/SemiPlot.UI/Minimap/OutageReport.cs new file mode 100644 index 0000000..15f6332 --- /dev/null +++ b/SemiPlot/SemiPlot.UI/Minimap/OutageReport.cs @@ -0,0 +1,33 @@ +using FluentResults; + +using Microsoft.Extensions.Logging; + +using SemiPlot.UI.Messages; + +namespace SemiPlot.UI.Minimap; + +/// docs/architecture/trend-interaction.md#archive-overview-minimap +internal sealed class OutageReport(string readName, MessagePanelViewModel messagePanel, ILogger logger) +{ + private bool _isInOutage; + + public void Succeeded() + { + _isInOutage = false; + } + + public void Failed(IReadOnlyList errors) + { + if (_isInOutage) + { + var error = errors[0]; + logger.LogWarning( + (error as IExceptionalError)?.Exception, "The {Read} failed again: {Reason}", readName, error.Message); + + return; + } + + _isInOutage = true; + messagePanel.TryReportFailure(errors, logger); + } +} diff --git a/docs/architecture/bench.md b/docs/architecture/bench.md index b0538c2..28e7c6a 100644 --- a/docs/architecture/bench.md +++ b/docs/architecture/bench.md @@ -326,14 +326,17 @@ is gitignored, so the password filled in there stays out of the repository. ## The headless render and input guards -Three classes in `SemiPlot.Tests.Unit` pin what a rendering-stack version bump can change without -announcing it: how a gap is drawn, and how a pointer reaches a handler. +Five classes in `SemiPlot.Tests.Unit` pin what a rendering-stack version bump can change without +announcing it: how a gap is drawn, how a pointer reaches a handler, and where a realised control sits +in the strip, at what size and with which figures. | Class | Drives | Asserts | | --- | --- | --- | | `UI/Chart/ChartGapRenderTests` | ScottPlot's rasteriser, no Avalonia | a `NaN` column leaves the rendered line broken, and a continuous series leaves no such break | | `UI/Chart/ChartPointerInputTests` | headless pointer events into `TrendChartView` | a drag pans the navigation window, a wheel zooms it, a capture loss ends the drag | -| `UI/Minimap/MinimapPointerInputTests` | headless pointer events into `MinimapView` | a drag on the strip moves the chart's window to each pointer fraction, a move after release moves nothing | +| `UI/Minimap/MinimapPointerInputTests` | headless pointer events into `MinimapView` | a drag on the strip moves the chart's window to each pointer fraction, a move after release reaches the strip and moves nothing, a window past the extent keeps its marker inside the strip | +| `UI/Minimap/MinimapHoverTests` | headless pointer events into `MinimapView` | a hover shows a line at the pointer and its time centred under it in the label row, inside the row at either end, where the time hides only the end label it covers, also after a new extent relabels that label; the line stays inside the strip at its right edge and keeps its fraction through a resize; a newer sample relabels the time; a drag keeps the line under the pointer, and a move off the strip or an exit hides the line and the time | +| `UI/Minimap/MinimapViewTests` | a realised `MinimapView` with a published band | the strip holds the band, the marker and the hover line in that order and no baseline; the band is sized to the strip, with figures in the pen's colour that a pan leaves as they are; the end labels sit in a row under the strip, and the view keeps its height when the extent fills them | **The render guard.** `Plot.RenderInMemory(width, height)` rasterises through SkiaSharp with no Avalonia in the loop, so it is a plain `[Fact]`; `Plot.RenderManager.LastRender.Layout.DataRect` diff --git a/docs/architecture/charting.md b/docs/architecture/charting.md index fed9183..02e295d 100644 --- a/docs/architecture/charting.md +++ b/docs/architecture/charting.md @@ -288,8 +288,14 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: painting its `Background`; without one it draws nothing and a press never reaches it. The handle shows only while `IsLegendVisible` holds. Nothing persists the state or the widths — every start opens expanded at 280. -- `Minimap/MinimapView` + `MinimapViewModel` — Canvas-based archive-overview strip; navigates via the - shared `ChartNavigationController` (see trend-interaction.md). +- `Minimap/MinimapView` + `MinimapViewModel` — Canvas-based archive-overview strip with no baseline, + and a label row under it that holds the two end labels and, under the pointer, the time at the strip's + 1 px hover line (`HoverAt`, `ClearHover`, `HoverFraction`, `HoverLabel`); navigates via the shared + `ChartNavigationController`, ends at the later of the extent's last sample and the navigation's + `NewestSample`; its `Minimap/MinimapBandFeed` reads the drawn pen's band over the whole strip + (`trend-interaction.md#archive-overview-minimap`). +- `Minimap/MinimapBand` — the strip's band, a `Control` with a `Render` override that fills and strokes + the figures `MinimapGeometry.BandFigures` shapes; it reads nothing. - `MainWindow/TrendWindow` — builds the window's parts once, in dependency order, and disposes them in reverse (`overview.md#one-window-per-process`). - `MainWindow/MainWindow` + `MainWindowViewModel` — the six-row window grid, the flags its View @@ -366,7 +372,11 @@ models, backed by renderer-agnostic models in `SemiPlot.Core`. Responsibilities: at empty leading/trailing edge sub-spans. Shared by the coarse-layer read path of every provider: each translates its own rows into the parallel `(timestamp, value?)` vocabulary where a null marks a gap. The Postgres Raw path runs no decimator; the server reduces the rows instead. -- `MinimapGeometry` — extent + window → strip start/width fractions, and fraction → timestamp. +- `MinimapGeometry` — extent + window → strip start/width fractions, and fraction → timestamp; + `MarkerSpan` → the marker's left edge and width, clamped inside the strip; `RightBound` → the later + of the extent's last sample and the newest sample; `BandFigures` → one outline and centre line per + run of finite envelope columns, scaled to the pen's own range. The band's next-read delay is the + read schedule's own, `Minimap/MinimapBandFeed.NextReadDelay`. - `CursorReadoutModel` — cursor X → per-pen interpolated `Center` value (gaps → no value). - `DeltaCursorModel` — two cursor times → `DeltaReadout` (Δt + Δy for the active pen). @@ -438,7 +448,8 @@ view model: query, returning one `PenHistoryEnvelope` per pen (ascending `Timestamps` + `Min` + `Max` + `Center`; NaN = gap). The initial load and every gesture re-query go through `ChartHistoryRequestDebouncer`, which runs one query at a time and lets the newest window asked - for run last. + for run last. The minimap band is a second reader outside the debouncer: one pen at 250 columns + every 2-60 s (`trend-interaction.md#archive-overview-minimap`). - **Realtime:** `IObservable` — an ascending union timeline the live edge advances from, plus one `PenRealtimeValues` per pen carrying that pen's **own** timestamps and `double` values; buffered on the data scheduler and observed on the UI scheduler. The values are per pen diff --git a/docs/architecture/data-integration.md b/docs/architecture/data-integration.md index 0d428db..8aedbda 100644 --- a/docs/architecture/data-integration.md +++ b/docs/architecture/data-integration.md @@ -617,7 +617,8 @@ hands on, so one bad batch does not end the realtime subscription. `Deliver` gua only: the result it reports instead is reported by a handler that guards itself. The catalogue read loop reports from the third failure in a row, the live edge's threshold (`ArchiveConnectionState.ConsecutiveFailuresBeforeFault`); the first two log a warning -(`overview.md#a-failed-read`). +(`overview.md#a-failed-read`). The minimap's band and extent reads each report the first failure of an +outage and log every repeat at Warning (`trend-interaction.md#archive-overview-minimap`). `Messages/ResultReporting` treats the log and the panel as two independent sinks: the panel edit runs first and the log line from a `finally`, so whichever of the two refuses the failure, the other still @@ -634,7 +635,10 @@ A handler that runs detached — an Rx `onNext`, an Rx `onError`, a job posted t its whole body, not its report alone, and hands the throw to `TryReportFailure`: `TrendChartViewModel.OnHistoryQueryFailed`, `MainWindow/AppStatusBarViewModel.ApplyConnectionState`, `Bridge/PenCatalogueSync.RunAsync`, `MainWindow/PenCatalogueApplier.ApplyAsync`, the last through -`TrendChartViewModel.ReportFailure`, and `App.ApplyTheme`, which holds a failure for the first window +`TrendChartViewModel.ReportFailure`, the minimap band's `Minimap/MinimapBandFeed.ApplyRead`, whose guard +covers the next read's scheduling too, and `TryRequestRead`, which the schedule and the pen-change +subscription call, `Minimap/MinimapViewModel.TryApplyExtent` for the extent read once more after a +blank one, and `App.ApplyTheme`, which holds a failure for the first window until it exists and has only the log after the failure window closes (`overview.md#the-live-theme`). The guard belongs to the handler rather than to whoever invokes it, because the recovery around the report would otherwise escape the same way the report can. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 2ed9477..d257e36 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -316,7 +316,8 @@ one method: nothing else may resolve a service through a static. query, reached through the debouncer by the initial load and every gesture alike) → provider returns one decimated `PenHistoryEnvelope` per pen (ascending `X` + `Min`/`Max`/`Center`) → the view model applies the result into the plot; the debouncer runs one query at a time and lets the newest - window asked for run last. + window asked for run last. The minimap band is a second reader outside the debouncer: one pen at 250 + columns every 2-60 s (`trend-interaction.md#archive-overview-minimap`). ## Deployment diff --git a/docs/architecture/testing-strategy.md b/docs/architecture/testing-strategy.md index cdb399f..7490dbc 100644 --- a/docs/architecture/testing-strategy.md +++ b/docs/architecture/testing-strategy.md @@ -114,11 +114,13 @@ writes run as `semiplot`, so a privilege that never reached the role fails here commissioning. The container is the delivery mechanism for a real server, nothing more. **Against a real Avalonia** — `SemiPlot.Tests.Unit/UI/`, under `[AvaloniaFact]`: -`ChartPointerInputTests`, `MinimapPointerInputTests`, `TrendChartViewTests`, `NavigationBarViewTests`, -`AppMenuBarTests`, `AppStatusBarViewTests`, `MainWindowViewTests`, `MessagePanelViewTests`, `ThemeTests`, +`ChartPointerInputTests`, `MinimapPointerInputTests`, `MinimapHoverTests`, `MinimapViewTests`, `TrendChartViewTests`, +`NavigationBarViewTests`, `AppMenuBarTests`, `AppStatusBarViewTests`, `MainWindowViewTests`, `MessagePanelViewTests`, `ThemeTests`, `StartupFailureWindowTests`, `TrendWindowTests`, `AppMainWindowTests`, `TrendCoordinatorTests` with `FakeDataProvider`. Seams guarded: the dispatcher, layout, hit-testing, pointer capture and event routing. Real framework, synthetic data. These are what catch a rendering-stack version bump. +The headless platform draws no pixels, so `Minimap/MinimapBand.Render` has no test: `MinimapViewTests` +pins the figures it receives, and the minimap's smoke walk on the demo stand covers what it paints. **Against a real rasterizer** — `SemiPlot.Tests.Unit/UI/Chart/ChartGapRenderTests.cs`, a plain `[Fact]` with no Avalonia: it renders through SkiaSharp and asserts on pixels that a `NaN` column breaks the @@ -193,8 +195,8 @@ A test that realises `TrendChartView` never passes `ImmediateScheduler.Instance` 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 hang belongs to `PenCatalogueSync`'s wait loop alone -(`CLAUDE.md`, Test). Two schedulers work in its place. +The schedule is one-shot, so nothing hangs; the hangs belong to `PenCatalogueSync`'s wait loop and the +minimap band's next-read schedule (`CLAUDE.md`, Test). Two schedulers work in its place. | Scheduler | When | Cost | | --- | --- | --- | diff --git a/docs/architecture/trend-feature-spec.md b/docs/architecture/trend-feature-spec.md index 09396a4..b28d69c 100644 --- a/docs/architecture/trend-feature-spec.md +++ b/docs/architecture/trend-feature-spec.md @@ -28,7 +28,7 @@ Acceptance-criterion labels: "UI" — verified in the running application; "Core ### TM-4 — Minimap (horizontal navigator) (MUST) **Definition.** An overview strip across the entire available archive extent with the current window highlighted. A click/drag on the minimap moves the window (recentering on a fraction of the extent). The extent is pulled from the data provider. -**Acceptance.** Core/test: `MinimapGeometry` — extent+window → (startFraction, widthFraction) and back fraction→time without loss of precision. UI: the highlight tracks every window change; a click on the minimap moves the window. +**Acceptance.** Core/test: `MinimapGeometry` — extent+window → (startFraction, widthFraction) and back fraction→time without loss of precision; the marker span stays inside the strip at any zoom and window position; the right bound is the later of the extent's last sample and the newest sample; an envelope with a break draws two band figures. UI: the highlight tracks every window change and every move of the right bound, and the marker never leaves the strip; a click on the minimap moves the window; the strip draws the drawn pen as a min/max band over the whole extent in the pen's colour, re-read on a schedule that follows the strip's span, and no drawn pen leaves no band; no baseline runs through the strip, and its two end labels sit in a row under it; a hover over the strip shows a line at the pointer and the time under it in that row, and leaving the strip hides both. ### TM-5 — Undo/Redo of time navigation (SHOULD) **Definition.** Undo/redo of the most recent horizontal shift and scale operations. diff --git a/docs/architecture/trend-interaction.md b/docs/architecture/trend-interaction.md index c04da7c..6643b7f 100644 --- a/docs/architecture/trend-interaction.md +++ b/docs/architecture/trend-interaction.md @@ -305,30 +305,152 @@ it read as the pen's measured extremes and was the padded axis bound. ## Archive-overview minimap -Required by trend-feature-spec.md §TM-4. As-built: a thin overview strip beneath the chart shows -the **full archive extent** with the current `[From, To]` view window highlighted, for orientation -and fast navigation across long archives. +Required by trend-feature-spec.md §TM-4. As-built: a 36 px overview strip beneath the chart shows +the **full archive extent** with the current `[From, To]` view window highlighted over a min/max band +of the drawn pen, for orientation and fast navigation across long archives. A label row under the +strip names its two ends and, while the pointer is over the strip, the time under it. - The extent comes from a new `IDataProvider.QueryArchiveExtentAsync()` seam returning an `ArchiveExtent(FirstUtc, LastUtc)` (data-integration.md), which `PostgresDataProvider` answers with the true bounds of the configured variables; an archive with no rows answers - `ArchiveExtent.Empty`. -- `Minimap/MinimapViewModel` reaches the extent through a `TrendCoordinator.QueryArchiveExtentAsync()` - pass-through (mirroring the `QueryHistoryAsync` seam + UI-scheduler discipline); it never holds the - `IDataProvider` directly. Extent → strip / window → fractions geometry is pure - (`Core/Trends/MinimapGeometry`). -- The strip reads the extent at start (`App.StartExtentLoad`), before a catalogue delta gives an empty - chart its first pens, and after a delta adds a pen to a chart that already has some + `ArchiveExtent.Empty`, and the strip stays blank. +- `Minimap/MinimapViewModel` reaches the archive through `TrendCoordinator.QueryArchiveExtentAsync()` + and `QueryHistoryAsync` on the UI scheduler; it never holds the `IDataProvider` directly. It takes + the chart (`TrendChartViewModel`) and reads its `Navigation`, `DrawnPenId`, `Pens` and `FindPen`. + The band is its own part, `Minimap/MinimapBandFeed`, which the view model builds, exposes as + `BandFeed` and disposes. The strip's geometry is pure (`Core/Trends/MinimapGeometry`). +- The strip reads the extent at start (`TrendWindow.StartExtentLoad`), before a catalogue delta gives + an empty chart its first pens, and after a delta adds a pen to a chart that already has some (`overview.md#what-a-read-changes`). `LoadExtentAsync` returns the read once the strip has applied it. Both callers widen the navigation's first sample to an earlier one in that read (`TrendChartViewModel.WidenToArchiveExtent`), so the chart pans back as far as the strip draws and reads the rows a moved floor brings into the window in view; the empty-chart caller seeds the - navigation from the same read first. -- `Minimap/MinimapView` is a Canvas-based strip (not a second `AvaPlot`): a highlight border sized - from `WindowStartFraction` / `WindowWidthFraction`. Press/drag converts pointer-X to a fraction → - `NavigateToFraction`, which recenters the window via the **same** `ChartNavigationController` the - chart navigates with. The highlight tracks every `WindowChanged` (pan / zoom and the sticky - live-edge advance). + navigation from the same read first. A read that leaves the strip blank, failed or empty, is read + once more on the next `NewestSampleMoved`: a sample exists, so the archive has rows. One such read + runs at a time, only a read that again leaves the strip blank arms the next one, and the next one + waits for a sample at least 60 s after the previous retry started, so an extent read that keeps + failing while the live edge works costs one read a minute. The extent reads share the band's + outage policy (see Failures). + +**The bounds.** The left bound (`ExtentFirst`) is the extent's first sample. The archive is never +pruned, so it moves only when a reloaded extent starts earlier, and no periodic extent read exists. +The right bound (`ExtentLast`) is `MinimapGeometry.RightBound`: the later of the extent's last sample +and `ChartNavigationController.NewestSample`, the newest sample the chart has seen. `TrackDataExtents` +writes it once, from the archive seed or the chart's first history, and afterwards only the live edge +(`OnLiveEdge`) moves it. `NewestSample` is null until one of the two writes it, so the controller's +wall-clock start never becomes a bound, and it only moves forward. `NewestSampleMoved` fires on every +forward move, sticky or not; when the window also moves, it fires after the move and ahead of +`WindowChanged`. The minimap recomputes the right bound, `ExtentLastLabel` and the window fractions +on it. An extent applied after a newer sample takes that sample as its right bound. + +**The marker.** `Minimap/MinimapView` draws the strip on a Canvas, `StripCanvas` (not a second +`AvaPlot`). A highlight border marks the window: `MinimapGeometry.MarkerSpan` turns +`WindowStartFraction` and `WindowWidthFraction` into a width of +`min(strip, max(6 px, widthFraction × strip))` and a left edge clamped to `[0, strip − width]`, so the +marker lies whole inside the strip at any zoom and window position. Press/drag converts pointer-X to a +fraction → `NavigateToFraction`, which recenters the window via the **same** +`ChartNavigationController` the chart navigates with. The highlight tracks every `WindowChanged` (pan / +zoom and the sticky live-edge advance) and every move of the right bound. + +**The label row.** `MinimapView` is a two-row grid: the 36 px strip (`StripCanvas`) on top and +`LabelRow`, one line of 10 px text with a 4 px side margin, under it. The strip draws the band, the +marker and the hover line and nothing else; no baseline runs through it, because a line through the +middle of the band is no zero and no value. The chart's own time labels sit directly above the strip, +so the end labels sit under it: `ExtentFirstLabel` at the row's left edge and `ExtentLastLabel` at its +right edge, both `MMM d HH:mm` in local time and in `AppSecondaryForegroundBrush`. `MainWindow.axaml` +sets no height on the minimap row, so the row takes the view's own height, the same with empty labels +as with filled ones. + +**The hover.** The code-behind takes `PointerMoved` and `PointerExited` on `StripCanvas` and hands the +pointer's strip fraction to `MinimapViewModel.HoverAt`, or calls `ClearHover`; a strip with no width +hovers and navigates nothing. `HoverAt` does nothing without an extent. The view model publishes +`HoverFraction` (`double?`, null while no hover shows) and `HoverLabel`, the time +`MinimapGeometry.TimeAtFraction` gives at that fraction, formatted as the end labels are; a move of +either bound relabels it. The view draws `HoverLine`, a 1 px `Border` above the marker in the chart +crosshair's `AppSecondaryForegroundBrush`, at the pointer's x, kept inside the strip by +`MinimapGeometry.SpanLeftWithin`. `MinimapGeometry.PlaceHoverTime` centres `HoverTimeLabel` under the +line in the label row, clamped inside the row, and decides which end label it covers from the widths of +the time and of the two end labels. The view measures the three texts as it places them, because a new +extent or a newer sample relabels an end label ahead of the layout pass. An end label the time covers, +or comes within the view's `EndLabelCoverDistance` of 6 px, takes `Opacity` 0, so its layout stays put +and it shows again once the time moves away. A press and drag navigate as before, and the line follows the pointer. A +captured drag keeps delivering moves past the strip's edges, so a move off the strip clears the hover; +capturing the pointer raises `PointerExited` while the pointer is still over the strip, so +`PointerExited` is ignored while a drag holds. + +**The band read.** `MinimapBandFeed` runs one read pipeline on the UI scheduler. Three things request +a read: an applied non-empty extent, a change of `DrawnPenId` (`WhenAnyValue` drops a raise with the +same value, and the value at construction is skipped), and the next-read schedule. Each request +snapshots the drawn pen, the bounds and the layer on the UI thread, drops the scheduled read, and +`Switch` cancels the read in flight through its `CancellationToken`. A request for a pen other than the +one `Band` holds turns `Band` null at once, so the band never shows one pen's shape in another pen's +colour, even when the new pen's read fails. An answer that lands after a newer request, already queued +on the UI scheduler when `Switch` dropped it, is discarded and schedules nothing. A read asks +`QueryHistoryAsync` for `[DrawnPenId]` over `[ExtentFirst, ExtentLast]` at +`MinimapBandFeed.MinimapColumns` (250) columns and logs its layer, column count and duration at Debug. +`Band` holds the drawn pen's envelope from the answer, null when the answer has none. With no drawn pen +or no extent the request is empty: `Band` turns null and no read runs or stays scheduled, so a chart +that draws no pen issues no band reads until one is drawn again. + +**The overview layer** is `ChartNavigationController.LayerForWidth(span, the band's previous layer, +250)`: the chart's own ladder at the strip's column count, so no second ladder exists. 250 columns +read `Raw` up to about 62 min, `Minute` up to about 62 h, `Hour` up to about 62 days and `Day` +beyond, and the hysteresis keeps a span at a boundary on the layer it already has. Every coarse read, +`Minute`, `Hour` or `Day`, includes the provider's raw fresh tail, up to four point spacings of one pen +(`data-integration.md#layer-ladder`). 250 columns are about 6 px each on a 1500 px strip. 250 is below +the chart's 256-column floor (`Chart/HistoryColumnTarget.MinColumns`), so no chart read asks for it, +and the test fake tells the band's reads from the chart's by that count +(`FakeDataProvider.BandHistoryQueries`). + +**The next-read schedule.** When a read lands, success or failure, the next read is scheduled +`MinimapBandFeed.NextReadDelay(span)` later: `clamp(span / 1000, 2 s, 60 s)` over the span that read +covered. A requested read drops the scheduled one, so a read slower than that delay is never cut off by +its own successor; the next read follows it one delay after it lands. Between reads the right bound +grows and the band's right end trails it by up to one delay; on a 1500 px strip that is 5 px at a +10-minute span, 1.5 px from 33 minutes to 16.7 hours, and past 16.7 hours a read a minute whose trail +shrinks with the span, about 1 px on a day and 0.15 px on a week. The schedule keeps reading while the +operator hides the minimap row, at the same cost as when the strip shows. + +**The band's shape** is pure geometry. `MinimapGeometry.BandFigures(band, first, last, width, +height)` maps the envelope to `BandFigure(Outline, CenterLine)` values in control coordinates, each +point a `BandPoint(X, Y)` (`Core/Trends/BandFigure.cs`). A column is drawn when it lies in `[first, +last]` and its min, max and centre are all finite; a NaN or an infinite value is a break. Each run of +drawn columns is one figure: its outline runs along the max line forward and the min line back, and its +centre line follows `Center`, so a break in the archive is a gap in the band. A run of one column is +drawn 2 px wide, kept inside the strip, so a short island between two breaks stays visible. The y range +is the pen's own, the min and max of the drawn columns, widened through `Core/Trends/ValueRange.Widen`, +the NaN-skipping rule `PenScaleModel` uses for its auto scale. The maximum maps to the top and the band +is never logarithmic. A flat range is padded by ±1, so a flat pen draws a band at mid-height. A zero +span, width or height, or an envelope with no drawn column, gives no figure. + +**The band control.** `Minimap/MinimapBand` is a `Control` with a `Render` override (avalonia.md, +custom drawing) and reads nothing. `Figures` and `BandColor` are styled properties registered with +`AffectsRender`; `Render` fills each outline with `BandColor` at 35 % opacity and strokes each centre +line 1 px wide at full colour. It sits below the marker and the hover line, with +`IsHitTestVisible="False"`, so a press on the band reaches the strip. `MinimapView.LayoutBand` +sizes it to the strip and computes its figures from `Band`, `ExtentFirst`, `ExtentLast` and the strip's +size; it runs on a resize of the strip and on a change of `HasExtent`, `ExtentFirst`, `ExtentLast` or +`Band`. `PlaceMarker` runs on a resize and on a change of the window fractions or `HasExtent`, so a pan +or a drag moves the marker and leaves the figures as they are. + +**The band's colour** follows the drawn pen's stored colour live. `BandColor` is a `WhenAnyValue` +over `DrawnPenId` and `Pens`, switched to that pen state's `Pen.Color` and ending in `ToProperty`, so a +recolour in the pen editor reaches the strip with the chart and needs no read. It is the stored hex +string, null with no drawn pen, and the view converts it with the legend's +`LegendConverters.HexToBrush`. + +**Failures.** A failed band read reaches the message panel through +`ResultReporting.TryReportFailure`, which maps it through `ArchiveFailureMapper.Map` like every +other read. `Minimap/OutageReport` holds the outage policy, one instance for the band reads in +`MinimapBandFeed` and one for the extent reads in `MinimapViewModel`: it reports the first failed read +of an outage and logs every further one at Warning, with the exception when the read threw, and the +first successful read ends the outage and arms the report again. The report starts armed. During an outage the catalogue +sync reports every 5 s with its own detail, and the panel coalesces a repeat of its newest entry only, +so a band entry per read would never coalesce; one entry per outage keeps the panel readable. The +catalogue sync answers its 5 s loop the other way: it logs its first two failures in a row and reports +from the third (`overview.md#a-failed-read`). Each read turns a throw into a failed `Result`, so a +throw never ends the pipeline and the next read is still scheduled; a throw while a read is applied, +the next read's scheduling included, goes to `TryReportFailure` as well. ## Out of scope / later diff --git a/docs/architecture/ui-theme.md b/docs/architecture/ui-theme.md index f758c30..658b68b 100644 --- a/docs/architecture/ui-theme.md +++ b/docs/architecture/ui-theme.md @@ -155,8 +155,8 @@ Semi owns the controls; these twelve keys are ours, and each exists in both vari | `AppPanelBackgroundBrush` | Navigation bar, legend panel, message panel, status bar, startup-failure window's message panel, minimap frame, chart hover readout | `#F7F8FA` | `#2B2D30` | | `AppContentBackgroundBrush` | Chart area, minimap strip canvas, the sidebar's resize handle | `#FFFFFF` | `#1E1F22` | | `AppBorderBrush` | Every separator in the three views | `#EBECF0` | `#393B40` | -| `AppSubtleLineBrush` | Minimap baseline, plot grid, the line above every sidebar group header but the first | `#EBECF0` | `#393B40` | -| `AppSecondaryForegroundBrush` | Minimap extent labels, chart crosshair, plot axis furniture, the sidebar group header caption and a row's unit | `#818594` | `#6F737A` | +| `AppSubtleLineBrush` | Plot grid, the line above every sidebar group header but the first, the status bar's separator after the connection indicator, the navigation bar's group separator | `#EBECF0` | `#393B40` | +| `AppSecondaryForegroundBrush` | Minimap end labels, hover line and hover time, chart crosshair, plot axis furniture, the sidebar group header caption and a row's unit | `#818594` | `#6F737A` | | `AppAccentBrush` | Minimap window highlight border, the active sidebar row's left bar | `#3574F0` | `#3574F0` | | `AppAccentFillBrush` | Minimap window highlight fill, the active sidebar row's background | `#3574F0` at 0.25 opacity | `#3574F0` at 0.25 opacity | | `AppSeverityErrorBrush` | The message panel's dot on an `Error` entry; a form's invalid field border and its message line | `#DB3B4B` | `#E55765` | @@ -167,7 +167,8 @@ Semi owns the controls; these twelve keys are ours, and each exists in both vari Pen colours are not theme keys. They come from the archive with the pen and stay per pen under both variants. The pen editor's colour swatch is the pen's own colour through `LegendConverters.HexToBrush`, -the converter the sidebar dot uses, and a pen with no colour shows a transparent swatch. +the converter the sidebar dot and the minimap band use, and a pen with no colour shows a transparent +swatch. ## A form never resizes on validation diff --git a/docs/plans/backlog.md b/docs/plans/backlog.md index b42a4ef..fe105ee 100644 --- a/docs/plans/backlog.md +++ b/docs/plans/backlog.md @@ -28,9 +28,8 @@ each is a scoped future task. killed process leaves none behind. Deferred from the external review of `live-theme` (2026-10-01): the race needs two operators saving the same file within one save's span. -- **Minimap — further work.** The current strip shows the window position over the extent (visible marker + - extent labels) but no data preview. Wanted: a richer overview (e.g. a downsampled trace/heat preview of the - archive, clearer window handles, possibly per-pen presence). Treat as its own task. +- **Minimap — further work.** The strip draws the drawn pen's min/max band under the window marker. Still + wanted: clearer window handles, and possibly a per-pen presence row. Treat as its own task. ## Rendering / performance @@ -77,7 +76,7 @@ Paths are under `SemiPlot/SemiPlot.UI/` unless they name another project. has no production writer and `PenScale.IsLogarithmic` (`SemiPlot.Core/Trends/PenScale.cs:9`) no reader; the log branches of `SemiPlot.Core/Trends/PenScaleModel.cs` run only under `PenScaleModelTests`. - **Disposed guards set inconsistently.** `ObjectDisposedException.ThrowIf` opens 11 members of - `Chart/TrendChartViewModel.cs` (`:219` to `:390`) and 2 of `Minimap/MinimapViewModel.cs` (`:84`, `:94`). + `Chart/TrendChartViewModel.cs` (`:219` to `:390`) and 2 of `Minimap/MinimapViewModel.cs` (`:92`, `:102`). Some guard real work: `RequestInitialHistory` (`:219`) and `ApplyCatalogue` (`:231`) push into the debouncer and the live edge. Others are missing on the paths the guarded `SetAxisLimits` (`:390`) shares: `AutoscalePen` (`:375`), `RestoreInitialScale(int)` (`:382`) and `WidenToArchiveExtent` (`:259`) carry no @@ -104,9 +103,9 @@ Paths are under `SemiPlot/SemiPlot.UI/` unless they name another project. - **`throw exception;` loses the stack trace.** `ArchiveExceptionMapper.Map` (`SemiPlot.DataSource.Postgres/ArchiveExceptionMapper.cs:28`) rethrows `OperationCanceledException` that way. - **Double UI hop.** `MinimapViewModel.LoadExtentAsync` resumes its `await` on the UI thread its callers run - on, then posts `ApplyExtent` to the UI scheduler again (`Minimap/MinimapViewModel.cs:86-87`). Under a - `TestScheduler` no `SynchronizationContext` exists, and the `Observable.Start` at `:87` is the one route - that brings `ApplyExtent` back, so dropping `:87` is no fix: keep one of the two marshalling routes. + on, then posts `ApplyExtent` to the UI scheduler again (`Minimap/MinimapViewModel.cs:94-95`). Under a + `TestScheduler` no `SynchronizationContext` exists, and the `Observable.Start` at `:95` is the one route + that brings `ApplyExtent` back, so dropping `:95` is no fix: keep one of the two marshalling routes. - **Allocation per pointer move.** Every `MoveCursor` (`Chart/TrendChartView.axaml.cs:305`) builds an array in `ChartCursorReader.ReadAt` (`Chart/ChartCursorReader.cs:13-16`) and a dictionary in `CursorReadoutModel.ReadAt` (`SemiPlot.Core/Trends/CursorReadoutModel.cs:9`). diff --git a/docs/plans/completed/20261002-minimap-overview.md b/docs/plans/completed/20261002-minimap-overview.md new file mode 100644 index 0000000..6309d0e --- /dev/null +++ b/docs/plans/completed/20261002-minimap-overview.md @@ -0,0 +1,508 @@ +# Minimap overview + +## Overview + +The minimap under the chart is a navigation strip with no data on it, and its window marker leaves the +strip once the chart follows live data past the extent read at start. This plan turns the strip into an +archive overview of the pen the chart draws, keeps the marker inside the strip at every zoom, and lets the +strip's right bound follow the newest sample. + +- **The marker stays in the strip.** At any zoom and any window position the marker lies inside the strip: + it keeps its minimum width and its left edge stops at `strip width - marker width`. +- **The right bound follows the newest sample.** The strip ends at the later of the archive extent's last + sample and the newest sample the chart has seen, and its end label moves with it, sticky or not. +- **The strip draws the drawn pen.** The pen whose axis the chart draws (`DrawnPenId`) is drawn as a + min/max band over the whole extent, scaled to its own range, in its own colour, from the coarsest layer + the strip's column count allows. A break in the archive is a gap in the band. No drawn pen, no band. +- **The strip carries no meaningless line, and its labels stay readable.** The baseline drawn through + the middle of the strip goes: it is no zero and no value. The two end labels move out of the strip into a + label row under it, because the chart's own time labels sit directly above the strip. +- **Hovering reads the time.** Under the pointer the strip shows a thin vertical line, and the label row + shows that point's date and time to the minute; leaving the strip hides both. +- **The band is re-read** at start, after a catalogue change that reloads the extent, when the drawn pen + changes, and on a schedule that follows the strip's span, so the band never drifts visibly from the + strip's right bound. + +The strip keeps its 36 px height; the label row under it adds one line of 10 px text. Closes #91: the marker's left edge is not clamped, and the strip's right +bound is the startup extent. + +## Context (from discovery) + +- `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml` - a `Canvas` with a baseline, two end labels and the + `WindowHighlight` border; nothing draws data. `MainWindow.axaml:78-80` gives it `Height="36"`. +- `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs:51-75` - `UpdateStrip` sizes the baseline and the + marker by hand; `:72-73` set `Left = start * width` and `Width = Math.Max(MinimumHighlightWidth, + widthFraction * width)` with `MinimumHighlightWidth = 6.0` (`:14`); the left edge has no upper clamp. +- `SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs:6-22` - `WindowFraction` clamps both window ends to + `[0, 1]`, so a window past `extentLast` yields start `1.0`. +- `SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs:82-90` - `LoadExtentAsync` reads the extent and + applies it (`ApplyExtent`, `:106-136`); `ExtentLast` changes nowhere else (`:128`). +- Callers of `LoadExtentAsync`: `MainWindow/TrendWindow.cs:141` (start) and + `MainWindow/PenCatalogueApplier.cs:58`, `:78` (catalogue deltas that add pens). +- `SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs:16`, `:22-24` - `_liveEdge` starts at the wall + clock, not at a sample; `:95` `TrackDataExtents` sets it to the archive's last sample; `:139-157` + `OnLiveEdge` keeps the later timestamp and raises `WindowChanged` only while sticky (`:146-149`). + `ChartRealtimeApplier.cs:15` calls `OnLiveEdge` with each realtime batch's last timestamp. +- `SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs:172-199` - `LayerForWidth(width, currentLayer, + targetColumnCount)`: the finest layer whose next-coarser spacing times the column count covers the + width, with hysteresis on the current layer. +- `SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs:91-108` - `ActivePenId` and `DrawnPenId`; + `:250`, `:281` raise `DrawnPenId` without checking that it changed. +- `SemiPlot/SemiPlot.UI/Bridge/TrendCoordinator.cs:92-100` - `QueryHistoryAsync(penIds, fromUtc, toUtc, + layer, targetColumnCount, cancellationToken)`. +- `SemiPlot/SemiPlot.Core/Trends/AggregationLayer.cs` - `Raw`, `Minute`, `Hour`, `Day` with point + spacings 1 s, 15 s, 15 min, 6 h. +- A break in an envelope is a NaN column in `Min`, `Max` and `Center` (`MinMaxDecimator.cs:225`, + `PostgresDataProvider.cs:457-460`); `PenScaleModel.cs:113-124` (`Widen`) holds the NaN-skipping range + rule. The stand seeds breaks every day (`bench.md:25`). +- `SemiPlot/SemiPlot.DataSource.Postgres/FreshTail.cs:41-62` - a coarse read reaching "now" adds raw rows + from the pen's newest coarse timestamp, at most four point spacings back from the window end + (`data-integration.md:382-390`). +- `SemiPlot/SemiPlot.UI/Messages/MessagePanelViewModel.cs:54-64`, `:86-89` - the panel coalesces a + repeat of its newest entry only. `Bridge/PenCatalogueSync.cs:46`, `:95` - the window's one periodic + reader runs on the UI scheduler. +- Tests: `SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs`, `UI/Minimap/MinimapViewModelTests.cs` + (builders at `:151`, `:158` pass `ImmediateScheduler.Instance`), `UI/Minimap/MinimapPointerInputTests.cs` + (`:124`, `:146` likewise), `UI/Chart/ChartNavigationControllerTests.cs`, `UI/Bridge/FakeDataProvider.cs` + (one `HistoryQueryCount` and one `LastQueriedPenIds` for every reader), and the window builders + `UI/MainWindow/MainWindowTestBuilder.cs`, `UI/Di/TrendWindowBuildTests.cs`, + `UI/Di/CompositionRootTests.cs`, `UI/MainWindow/TrendWindowTests.cs`, + `UI/Startup/EmptyCatalogueStartupTests.cs`, `UI/MainWindow/PenCatalogueApplierTests.cs`. +- Docs: `docs/architecture/trend-interaction.md:306-331` (Archive-overview minimap, with the stale + `App.StartExtentLoad` at `:320`), `charting.md:291` and `:369`, `trend-feature-spec.md:29-31` (TM-4). + +## Development Approach + +- **testing approach**: TDD - each defect's test is written first and fails on `master` by assertion, not + by a missing member. +- complete each task fully before moving to the next; small, focused changes. +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task. +- **CRITICAL: all tests must pass before starting the next task.** +- **CRITICAL: update this plan file when scope changes during implementation.** +- Every new or touched test class runs alone with `--filter`. A test that builds `MinimapViewModel` passes a + `TestScheduler` as its UI scheduler and never calls `TestScheduler.Start()` once the band's next read is + scheduled; `ImmediateScheduler` sleeps through a delayed schedule on the calling thread (CLAUDE.md, + Test). + +## Testing Strategy + +- **unit tests**: the marker span, the band's figures and the right bound as plain `[Fact]`s in + `Core/Trends`; the view model's reads, triggers, cancellation and failure reporting as `[AvaloniaFact]`s + over a `TestScheduler` and `FakeDataProvider`; the marker placement and the band's size as headless view + tests. +- **container tests**: none; the stand's span always picks `Minute` (Technical Details), which no fresh + tail makes expensive. +- **e2e tests**: none; the demo stand smoke checklist covers what the eye sees. + +## Acceptance Evidence + +Today (verified 2026-10-02 on the demo stand): the strip shows no data, and after a few minutes of live +data with a 10-minute window the marker sits past the strip's right edge while the end label still reads +the start time. + +Automated, from the repository root: + +1. `dotnet build SemiPlot.slnx` - 0 warnings, 0 errors. +2. `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj` - no failures. +3. These named tests exist and pass (`--filter "FullyQualifiedName~"`); the first two fail on + `master` by assertion: + - `MinimapPointerInputTests.AWindowPastTheExtent_KeepsItsMarkerInsideTheStrip` (headless); + - `MinimapViewModelTests.ANewerSampleThanTheExtent_MovesTheRightBoundAndItsLabel`; + - `MinimapGeometryTests.ABandWithABreak_DrawsTwoFigures`, `AFlatPen_DrawsAMidLineBand`, + `AnAllNaNBand_DrawsNothing`; + - `MinimapBandFeedTests.ASevenDayExtent_ReadsTheDrawnPenAtTheHourLayer`; + - `MinimapBandFeedTests.ASupersededBandRead_IsCancelled`; + - `MinimapBandFeedTests.AnOutage_ReportsOnceAndLogsEveryRepeat`. +4. `git grep -n "QueryHistoryAsync" -- SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs` prints nothing: the + band control draws and never reads. +5. `MinimapViewTests.TheStrip_DrawsNoBaselineAndLabelsItsEndsUnderTheStrip` and + `MinimapHoverTests.HoveringTheStrip_ShowsALineAndItsTimeAndLeavingHidesThem` exist and pass. + +Manual smoke checklist on the demo stand (`dotnet run --project SemiPlot/SemiPlot.AppHost`): + +1. The strip shows a band in the drawn pen's colour across the whole archive within 2 s of start, with a + gap at each seeded break. +2. Click another pen in the sidebar so its axis is drawn: the band switches to that pen within 2 s. +3. Recolour the drawn pen in the pen editor: the band takes the new colour within 5 s, with the chart. +4. Hide the drawn pen with nothing else shown: the band disappears; show a pen again: it returns. +5. Follow live data with a 15 s window for 5 minutes: the marker stays at the strip's right edge, fully + visible, and the end label advances with the live edge. +6. Zoom in to a few seconds and pan to both ends of the archive: the marker never leaves the strip. +7. Click and drag on the strip: the chart window recentres as before. +8. Watch the strip for 3 minutes: at each re-read (every 60 s on the stand's day) the band grows at its + right end and its existing shape does not visibly jump. +9. No line runs through the middle of the strip; the two end labels sit in a row under it, readable, and + do not touch the chart's time labels above. +10. Move the pointer over the strip: a thin vertical line follows it and the row under the strip shows its + date and time to the minute under the pointer; move the pointer off the strip: both disappear. Press + and drag still recentre the chart window. + +## Progress Tracking + +- mark completed items with `[x]` immediately when done +- add newly discovered tasks with ➕ prefix +- document issues/blockers with ⚠️ prefix +- update plan if implementation deviates from original scope + +## Solution Overview + +The strip has three jobs and each gets one owner. + +- **Marker placement** is pure geometry in `Core/Trends/MinimapGeometry`: from the window fractions, the + strip width and the minimum marker width it returns the marker's left edge and width, both inside + `[0, strip width]`. The view only applies them. +- **The bounds.** The left bound is the extent's first sample. The right bound is the later of the + extent's last sample and the navigation's newest sample. The navigation already owns that timestamp + (`_liveEdge`); it exposes it as `NewestSample`, absent until a sample sets it, so the constructor's wall + clock never becomes a bound. It raises `NewestSampleMoved` on every write, sticky or not, and the + minimap recomputes its right bound, end label and marker on that event. The archive is never pruned, so + the left bound moves only when a catalogue delta adds a pen with older rows, which already reloads the + extent; no periodic extent read exists. +- **The band.** `MinimapViewModel` owns one read pipeline on the UI scheduler. Its requests come from the + extent apply, a distinct `DrawnPenId` change and the next-read schedule; each request snapshots the pen + id and the bounds on the UI thread, and `Switch` cancels the read in flight through its + `CancellationToken`. Each read asks `TrendCoordinator.QueryHistoryAsync` for the drawn pen over + `[left bound, right bound]` and publishes the envelope. When a read lands, success or failure, the next + read is scheduled `clamp(span / 1000, 2 s, 60 s)` later, where `span` is the strip's span at that + moment; a read slower than that delay is therefore never cut off by its own successor. Between reads the + right bound grows and the band's right end trails it by one delay. Three cases follow on a 1500 px + strip: an archive under 33 minutes reads every 2 s and trails by up to about 5 px; from 33 minutes to + 16.7 hours the delay is a thousandth of the span and the trail is 1.5 px; past 16.7 hours the band is + read once a minute and the trail shrinks with the span, about 1 px on the stand's day and below a pixel + from a week on. A `Hour` or `Day` read includes the provider's raw fresh tail, up to four point + spacings of one pen; ASSUMPTION: once a minute that tail is cheap on an installation, which the + months-long archive check in Post-Completion measures. ASSUMPTION: the provider's bucketed `Raw` read + places bucket edges so that a read a few seconds later draws the same shape; if the band visibly + shimmers on a young archive, the 2 s floor rises. +- **The overview layer** is `ChartNavigationController.LayerForWidth(span, previousLayer, + MinimapColumns)` with `MinimapColumns = 250`: the existing ladder with the strip's own column count, + so no second ladder exists. 250 columns read `Raw` up to about 62 min, `Minute` up to about 62 h, + `Hour` up to about 62 days and `Day` beyond, and the hysteresis keeps a span at a boundary on the layer + it already has. 250 columns are about 6 px each on a 1500 px strip, which a 36 px band still reads as a + shape, and they bound a read at about 15 000 `Minute` or 6 000 `Hour` rows per pen at the top of each + layer. 250 is below the chart's 256-column floor (`HistoryColumnTarget.cs:7`), so it never equals a + chart read's column count, and a test fake tells the band's reads from the chart's by that count. +- **The band's shape** is pure geometry in Core: the envelope maps to figures, one per run of non-NaN + columns, each a polygon of the max line forward and the min line back plus its centre line, over a + value range that skips NaN (the `PenScaleModel.Widen` rule, shared rather than copied) and pads a flat + range to a centred band. `Minimap/MinimapBand`, a `Control` with a `Render` override (avalonia.md, + custom drawing), only draws those figures. +- **The band's colour** follows the drawn pen's stored colour live: a `WhenAnyValue` over `DrawnPenId` and + that pen state's `Pen`, ending in `ToProperty`, so a recolour in the pen editor reaches the strip with + the chart and needs no read. +- **Failures.** A failed band read reports through `ResultReporting.TryReportFailure`, which maps it + through `ArchiveFailureMapper.Map` like every other read. During an outage the catalogue sync reports + every 5 s with its own detail, so the panel's newest-entry coalescing never matches the band's repeat; + the band therefore reports the first failure of an outage and logs every further one at Warning, and the + first successful read arms the report again. The report starts armed. `Minimap/OutageReport` holds that + policy once, and the extent re-read of a strip left blank at start uses it too, re-reading at most once a + minute. Each read catches its own exception into a failed `Result`, so a throw never ends the pipeline. + The catalogue sync's policy (suppress the first failures, then report each) answers a 5 s loop where one + slow read is noise; the band's first failure is already worth a line. + +## Technical Details + +- `MinimapGeometry.MarkerSpan(double startFraction, double widthFraction, double stripWidth, double + minimumWidth) -> (double Left, double Width)`: `Width = min(stripWidth, max(minimumWidth, widthFraction + * stripWidth))`, `Left = clamp(startFraction * stripWidth, 0, stripWidth - Width)`. A zero or negative + strip width returns `(0, 0)`. +- `MinimapGeometry.RightBound(DateTime extentLast, DateTime? newestSample)`: the later of the two. +- `MinimapGeometry.BandFigures(PenHistoryEnvelope band, DateTime first, DateTime last, double width, double + height) -> IReadOnlyList`, where `BandFigure` holds the polygon's points and the centre + line's points in control coordinates. Columns outside `[first, last]` are dropped; a flat range + `max == min` is padded to `±1` around the value before mapping (the band then sits at mid-height); an + envelope with no finite value gives no figure. The NaN-skipping widen moves from a private + `PenScaleModel.Widen` to one internal Core helper both call. +- `ChartNavigationController`: `public DateTime? NewestSample` (null until `OnLiveEdge` or + `TrackDataExtents` writes it) and `public event EventHandler? NewestSampleMoved`, raised on + every write. `_liveEdge` keeps its wall-clock start for `Pan` and `JumpToNow`. +- `MinimapViewModel(TrendCoordinator coordinator, TrendChartViewModel chart, IScheduler uiScheduler, + MessagePanelViewModel messagePanel, ILogger logger)`: it reads `chart.Navigation`, + `chart.DrawnPenId` and `chart.FindPen`. `ExtentLast` becomes the right bound. `Band` + (`PenHistoryEnvelope?`) and `BandColor` are the published band; `Band` is null with no drawn pen or no + extent. `MinimapColumns = 250`. The next-read delay is `MinimapGeometry.NextReadDelay(TimeSpan span)` = + `clamp(span / 1000, 2 s, 60 s)`, a pure Core function. Each band read logs its layer, column count and duration at Debug. +- `FakeDataProvider` logs every history query (`penIds`, `fromUtc`, `toUtc`, `layer`, + `targetColumnCount`); the chart-side counts and pen-id assertions read only queries whose column count + is not 250. +- `MinimapBand : Control`: `Figures` and `BandColor` as styled properties registered with `AffectsRender`; + the view model's figures are recomputed from `Band`, the bounds and the control's size. `Render` fills + each polygon with `BandColor` at 35 % opacity and strokes its centre line at full colour. + `MinimapView.LayoutBand` sizes it to the strip, and it sits below `WindowHighlight` and `HoverLine`, + `IsHitTestVisible="False"`. +- The schedule keeps reading while the operator hides the minimap row; its cost is the same as when the + strip is shown. + +- The label row: `MinimapView` becomes a two-row grid, the 36 px strip on top and a label row of one + text line under it (about 14 px); `MainWindow.axaml` sets no height on the view, so the minimap row + takes the view's own height and grows by the label line. `ExtentFirstLabel` sits at the row's left edge and `ExtentLastLabel` at its right edge. + The labels use the strip's existing foreground key from `Palette.axaml`; no new colour. +- Hover: the code-behind takes `PointerMoved` and `PointerExited` on `StripCanvas` (avalonia.md: input in + code-behind, the decision in the view model) and hands the pointer's fraction to + `MinimapViewModel.HoverAt(double fraction)` and `ClearHover()`. The view model publishes `HoverFraction` + (`double?`) and `HoverLabel`, the time `MinimapGeometry.TimeAtFraction` gives, formatted as the end labels + are. The view draws a 1 px line at that fraction over the band and centres the time under it in the label + row, clamped inside the row; while it shows, an end label it would overlap is hidden. Nothing is shown + without an extent. A drag keeps navigating as before, and the hover line follows the pointer during it. + +## What Goes Where + +- **Implementation Steps**: code, tests and docs in this repository. +- **Post-Completion**: the operator's walk on the stand and on a months-long archive. + +## Implementation Steps + +### Task 1: Keep the marker inside the strip + +**Files:** +- Modify: `SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapPointerInputTests.cs` + +- [x] write `AWindowPastTheExtent_KeepsItsMarkerInsideTheStrip` on the existing headless setup: a window + past the extent gives `Canvas.GetLeft(WindowHighlight) + WindowHighlight.Width <= + StripCanvas.Bounds.Width`; it fails on `master` by assertion +- [x] add `MinimapGeometry.MarkerSpan` as Technical Details states; `UpdateStrip` places `WindowHighlight` + from it +- [x] write geometry tests: start 1.0 and 0.999 keep `Left + Width <= stripWidth`; a window wider than + the strip fills it exactly; a zero strip width gives `(0, 0)`; a window at the left edge keeps `Left + == 0` +- [x] run the unit tests - must pass before task 2 + +### Task 2: Let the right bound follow the newest sample + +**Files:** +- Modify: `SemiPlot/SemiPlot.UI/Chart/ChartNavigationController.cs` +- Modify: `SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs` +- Modify: `SemiPlot/SemiPlot.UI/MainWindow/TrendWindow.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapPointerInputTests.cs` + +- [x] write `ANewerSampleThanTheExtent_MovesTheRightBoundAndItsLabel`: an extent ending at T and a realtime + sample at T + 5 min move `ExtentLast` and `ExtentLastLabel`, sticky and not sticky; it fails on + `master` by assertion +- [x] add `NewestSample` and `NewestSampleMoved`; add `MinimapGeometry.RightBound` +- [x] `MinimapViewModel` takes the chart in place of the bare navigation (Technical Details) and the right + bound on `ApplyExtent` and on `NewestSampleMoved`; `TrendWindow.Build` passes the chart; both + minimap test builders move to a `TestScheduler` UI scheduler and a chart built over one +- [x] write tests: `NewestSample` is null on a fresh controller and set by `TrackDataExtents` and + `OnLiveEdge`; the event fires on each write; an extent ending later than the newest sample wins; no + extent applied leaves the strip blank; the existing extent and label tests keep their expectations + (➕ as built: `NewestSample` only moves forward, so an older sample is no write and raises nothing; a + sample newer than the extent seen before the extent lands is the right bound when it lands; + `MinimapGeometryTests.RightBound_IsTheLaterOfTheExtentAndTheNewestSample` pins the rule) +- [x] run the unit tests - must pass before task 3 + +### Task 3: Shape the band in Core + +**Files:** +- Modify: `SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs` +- Modify: `SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs` +- Create: the shared NaN-skipping range helper in `SemiPlot/SemiPlot.Core/Trends/` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs` + +- [x] move `PenScaleModel.Widen`'s rule into one internal helper both call; `PenScaleModel`'s tests stay + green unchanged (➕ as built: `Core/Trends/ValueRange.Widen`) +- [x] add `BandFigures`, `BandFigure` and `NextReadDelay` as Technical Details states (➕ as built: + `BandFigure(Outline, CenterLine)` and its `BandPoint(X, Y)` live in `Core/Trends/BandFigure.cs`; a + column is drawn when its min, max and centre are all finite, and the range comes from drawn columns + only; a run of one column is drawn 2 px wide; a zero span, width or height gives no figure; the band + is never logarithmic) +- [x] write `ABandWithABreak_DrawsTwoFigures`, `AFlatPen_DrawsAMidLineBand`, `AnAllNaNBand_DrawsNothing`, + and a test that columns outside `[first, last]` are dropped and the y axis maps max to the top; + `NextReadDelay` gives 2 s for 10 min, 3.6 s for 1 h, 60 s for 1 day and 60 s for 1 year +- [x] run the unit tests - must pass before task 4 + +### Task 4: Read the drawn pen's band + +**Files:** +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs` +- Modify: the window builders Context lists, where they construct the minimap or assert on history reads + +- [x] write `ASevenDayExtent_ReadsTheDrawnPenAtTheHourLayer`: a seven-day extent issues one history read + for `[DrawnPenId]` over `[ExtentFirst, ExtentLast]` at `Hour` and 250 columns, and `Band` holds its + envelope +- [x] add the read pipeline, the next-read schedule, `BandColor` and the failure policy as Solution + Overview states (➕ as built: the band, its reads, schedule, outage policy and colour live in + `Minimap/MinimapBandFeed`, which `MinimapViewModel` builds and exposes as `BandFeed`; each read is an + `Observable.Create` over a read that answers a throw with a failed `Result`; the `DrawnPenId` trigger + skips the value it holds at construction; `BandColor` is the stored hex string, null with no drawn + pen; a request for another pen clears `Band` at once, a requested read drops the scheduled one, and + an answer landing after a newer request is discarded; a failed or empty extent is read once more on + the next `NewestSampleMoved`) +- [x] `FakeDataProvider` logs every query; `PenCatalogueApplierTests` and the window builders assert on the + chart's reads only (column count not 250), and their idle-chart checks stay as strict as on `master` + (➕ as built: `HistoryQueries`, `ChartHistoryQueries` and `BandHistoryQueries`, with `HistoryQueryCount` + and `LastQueried*` read from the log; `HistoryReadException` makes a read throw; of the window builders + only `PenCatalogueApplierTests` asserts on history reads) +- [x] write `ASupersededBandRead_IsCancelled`: a `DrawnPenId` change while a read is held cancels its token + and only the new pen's envelope lands +- [x] write `AnOutage_ReportsOnceAndLogsEveryRepeat`: the first failed read adds one panel entry, the next + ones add none and log at Warning, a success then a failure adds one more (➕ as built: a catalogue-sync + entry tops the panel before each repeat, so the panel's newest-entry coalescing cannot hide a repeat) +- [x] write tests: no drawn pen gives `Band == null` and no read; a `DrawnPenId` raise with the same id + issues no read; the next read lands `NextReadDelay(span)` after the last one landed; a throwing read reports + and the next scheduled read still runs; `BandColor` follows a recolour without a read; dispose + cancels the scheduled read (➕ as built: also `AReloadedExtent_ReadsTheBandAgain` and + `HidingTheDrawnPen_ClearsTheBandAndStopsTheReads`) +- [x] run the unit tests - must pass before task 5 + +### Task 5: Draw the band + +**Files:** +- Create: `SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs` +- Create: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs` + +- [x] add `MinimapBand` as Technical Details states; `UpdateStrip` sizes it to the strip (➕ as built: + `BandColor` is an `IBrush?` bound through `LegendConverters.HexToBrush`, the legend's own converter; + `MinimapView.LayoutBand` computes the figures from `Band`, `ExtentFirst`, `ExtentLast` and the strip's + size and runs only on those and a resize, while `PlaceMarker` follows the window fractions; the band + sits below the end labels as well as the marker) +- [x] write `MinimapViewTests`: the realised strip holds one `MinimapBand` above the baseline and below the + marker, its `Bounds.Size` equals `StripCanvas.Bounds.Size`, a published band gives it figures, a null + band leaves it none; pointer navigation still recentres the window (➕ as built: also the band's brush + is the drawn pen's colour, and hiding every pen takes the figures away) +- [x] run the unit tests - must pass before task 6 + +### Task 6: Verify acceptance criteria + +- [x] every automated item of Acceptance Evidence passes (➕ as built: build 0 warnings, 0 errors; unit + suite 1632 passed, 0 failed, 0 skipped; each named test passes alone, the right-bound theory as 2 + cases; the `MinimapBand.cs` grep prints nothing) +- [x] `dotnet format SemiPlot.slnx --verify-no-changes` exits 0 +- [x] `dotnet terse` over every touched `.cs` file exits 0 (➕ as built: the 18 files of + `git diff --name-only master...HEAD -- '*.cs'`) +- [x] the manual smoke checklist: the operator's walk before delivery (skipped - not automatable) + +### Task 7: [Final] Update documentation + +- [x] `trend-interaction.md` "Archive-overview minimap": the band and its shape, the overview layer, the + read triggers and the next-read schedule, the right bound at the newest sample, the marker clamp, the + failure policy; replace the stale `App.StartExtentLoad` with `TrendWindow.StartExtentLoad` + (➕ as built: `data-integration.md`'s "No failure stops at the log" names the band's outage policy + with a pointer to the section, which `OutageReport` also points at) +- [x] `charting.md:291` and `:369`: `MinimapBand`, `MinimapGeometry.MarkerSpan`, `RightBound`, + `BandFigures` (➕ as built: also `NextReadDelay`) +- [x] `trend-feature-spec.md` TM-4 acceptance: the strip draws the drawn pen and the marker stays inside it + (➕ as built: `bench.md`'s headless guard table lists `MinimapViewTests` and the marker case of + `MinimapPointerInputTests`; `testing-strategy.md` lists `MinimapViewTests` among the real-Avalonia + tests) +- [x] the plan moves to `docs/plans/completed/` in the delivery commit (skipped - the delivery commit + moves it; this pass leaves the file in place) + +### Task 8: Clear the strip and label its ends underneath + +**Files:** +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs` +- Modify: `SemiPlot/SemiPlot.UI/MainWindow/MainWindow.axaml` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs` + +- [x] write `TheStrip_DrawsNoBaselineAndLabelsItsEndsUnderTheStrip` (headless): the realised strip holds no + baseline, both end labels lie below the strip's bottom edge, inside the view, at its left and right + edges; it fails before the change by assertion (➕ as built: the strip's children are exactly + `BandLayer`, `WindowHighlight` and `HoverLine` in that order, and the two labels do not overlap; + `MainWindowViewTests.TheMinimapRow_ShowsTheWholeLabelRowUnderTheStrip` pins the row's height) +- [x] delete `Baseline` and its layout; move the end labels into the label row under the strip as Technical + Details states; size the view in `MainWindow.axaml` (➕ as built: the view is a `Grid` of rows `36,Auto`, + the label row a `Panel` named `LabelRow` with a 4 px side margin, the labels aligned left and right; + `MainWindow.axaml` sets no height, so the minimap row takes the view's own height, the same with + empty labels as with filled ones: about 51 px on a real font, 48 px under the headless text stub) +- [x] keep the band, the marker and the pointer navigation tests green; update tests that looked up the + baseline or the labels' canvas positions (➕ as built: `TheStrip_HoldsOneBandBelowTheMarker` replaces the + band-above-the-baseline test; `MinimapViewStand` exposes `View` and sizes its window to the view's + height, as the minimap row does) +- [x] run the unit tests - must pass before task 9 + +### Task 9: Show the time under the pointer + +**Files:** +- Modify: `SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapViewModel.cs` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml` +- Modify: `SemiPlot/SemiPlot.UI/Minimap/MinimapView.axaml.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapViewStand.cs` +- Create: `SemiPlot/SemiPlot.Tests.Unit/UI/Minimap/MinimapHoverTests.cs` + +- [x] write `HoveringTheStrip_ShowsALineAndItsTimeAndLeavingHidesThem` (headless): a pointer move over the + strip shows the hover line at the pointer's x and the time `TimeAtFraction` gives, centred under it; + a pointer exit hides both; it fails before the change by assertion +- [x] add `HoverAt`, `ClearHover`, `HoverFraction` and `HoverLabel` to `MinimapViewModel`; wire the pointer + events in the code-behind and draw the line and the label as Technical Details states (➕ as built: + the line is a 1 px `Border` named `HoverLine` above the marker in `AppSecondaryForegroundBrush`, the + chart crosshair's key; the time is `HoverTimeLabel` on a `Canvas` over `LabelRow`; a covered end label + takes `Opacity` 0, so its bounds stay laid out, and the view's `EndLabelCoverDistance` of 6 px, passed + to `MinimapGeometry.PlaceHoverTime`, counts as covering; a move off the strip during a captured drag + clears the hover, and `PointerExited` is ignored while the drag holds, because the capture itself + raises it) +- [x] write tests: no extent shows nothing on hover; a hover at either end keeps the label inside the row + and hides the end label it would overlap; a press and drag still recentre the window and the line + follows the pointer (➕ as built: `MinimapHoverTests` holds the headless hover cases; also a newer + sample relabels a hover at the right end) +- [x] run the unit tests - must pass before task 10 + +### Task 10: Verify and document the label row and the hover + +- [x] every automated item of Acceptance Evidence passes; `dotnet format SemiPlot.slnx --verify-no-changes` + and `dotnet terse` over every touched `.cs` file exit 0 (➕ as built: build 0 warnings, 0 errors; unit + suite 1654 passed, 0 failed, 0 skipped; each of the ten named tests passes alone, the right-bound + theory as 2 cases; the `MinimapBand.cs` grep prints nothing; terse over the 22 files of + `git diff --name-only master...HEAD -- '*.cs'`) +- [x] `trend-interaction.md` "Archive-overview minimap" and `charting.md`'s `MinimapView` entry: the label + row under the strip, the hover line and time, no baseline (➕ as built: "The label row" and "The + hover" paragraphs follow "The marker"; the band control sits below the marker and the hover line) +- [x] ➕ `ui-theme.md:158` drops "Minimap baseline" from `AppSubtleLineBrush`'s users; `bench.md:338`'s + `MinimapViewTests` row states the band below the marker, no baseline and the label row (➕ as built: + `AppSecondaryForegroundBrush` lists the minimap's end labels, hover line and hover time; TM-4's + acceptance in `trend-feature-spec.md` names the label row and the hover) +- [x] the manual smoke checklist steps 9-10: the operator's walk before delivery (skipped - not + automatable) + +## Post-Completion + +**Manual verification** +- Walk the smoke checklist on the demo stand, including a 5-minute live follow at a 15 s window. +- On an installation with a months-long archive, check that the band appears within 2 s of start and that + the band trails the live edge by no more than a pixel or two, and that one band read, fresh tail + included, takes well under a second in the log at `--logging-level debug`. + +**Executed by exec:** +- branch: minimap-overview + +## Verify it yourself + +1. Build and tests, from the repository root: + - `dotnet build SemiPlot.slnx` - 0 warnings, 0 errors. + - `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj` - 1668 passed. Master's + `BreakGenerationTests.NoRowFallsInsideABreak` alone takes about 2 minutes; that is not a hang. + - `git grep -n "QueryHistoryAsync" -- SemiPlot/SemiPlot.UI/Minimap/MinimapBand.cs` prints nothing. +2. The two defects of #91, by test (`--filter "FullyQualifiedName~"`); each failed by assertion against + the code before its fix and passes from `02caa09` and `7de132c` on: + - `MinimapPointerInputTests.AWindowPastTheExtent_KeepsItsMarkerInsideTheStrip` - the marker's right edge + was 906 px on a 900 px strip; + - `MinimapViewModelTests.ANewerSampleThanTheExtent_MovesTheRightBoundAndItsLabel` - the right bound + stayed at the startup extent, sticky or not. +3. The band, by test: `MinimapGeometryTests` (gaps, a flat pen, all-NaN, an infinite column, a lone column, + the hover time's placement), `MinimapBandFeedTests` (the layer, the read schedule, cancellation, a stale + answer after a pen change, the outage policy), `MinimapViewTests` (the strip holds exactly the band, the + marker and the hover line; the end labels sit in the row under the strip). +4. The label row and the hover, by test: `MainWindowViewTests.TheMinimapRow_ShowsTheWholeLabelRowUnderTheStrip` + fails with the old `Height="36"` on the minimap in `MainWindow.axaml`; `MinimapHoverTests` (the line and + its time follow the pointer and hide on exit, the time stays in the row and hides only the end label it + covers, a drag keeps the line under the pointer until it leaves the strip). +5. On the demo stand (`dotnet run --project SemiPlot/SemiPlot.AppHost`), the manual smoke checklist of + Acceptance Evidence, steps 1-10. Before the change the strip showed only labels and a marker over a line + through its middle; after it the strip shows the drawn pen's band with a gap at each seeded break, the + end labels sit readable in a row under it, and hovering shows a line and its time.