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