Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions SemiPlot/SemiPlot.Core/Trends/BandFigure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SemiPlot.Core.Trends;

/// <summary>One unbroken run of the minimap band in control coordinates: max forward, min back, and the centre.</summary>
public sealed record BandFigure(IReadOnlyList<BandPoint> Outline, IReadOnlyList<BandPoint> CenterLine);

public readonly record struct BandPoint(double X, double Y);
188 changes: 188 additions & 0 deletions SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
namespace SemiPlot.Core.Trends;

/// <summary>Where the hover time sits in the label row, and which end label it hides.</summary>
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,
Expand All @@ -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);
}

/// <summary>Moves a span's left edge so it lies inside [0, limit]; a wider span starts at 0.</summary>
public static double SpanLeftWithin(double left, double width, double limit)
{
return Math.Max(0.0, Math.Min(left, limit - width));
}

/// <summary>Centres the hover time on the pointer in a label row whose end labels sit at its edges.</summary>
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<BandFigure> 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<BandFigure>();
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<BandPoint>(2 * (end - start));
var centerLine = new List<BandPoint>(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;
}
}
}
17 changes: 2 additions & 15 deletions SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime> timestamps, DateTime windowStart)
{
Expand Down
17 changes: 17 additions & 0 deletions SemiPlot/SemiPlot.Core/Trends/ValueRange.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading