Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
292b242
perf: viewport-aware downsampling and 60fps render throttling for min…
tylerkron Apr 11, 2026
44d7b02
fix: restore full-range data on ResetZoom to prevent data clipping
tylerkron Apr 11, 2026
a567f90
fix: use explicit time axis range in ResetZoom instead of auto-range
tylerkron Apr 11, 2026
65f70df
fix: force OxyPlot to re-read ItemsSource after viewport downsample u…
tylerkron Apr 11, 2026
28dc2cc
perf: eliminate GC pressure and throttle main plot viewport updates
tylerkron Apr 11, 2026
be35c1b
docs: document OxyPlot performance patterns and gotchas in CLAUDE.md
tylerkron Apr 11, 2026
80eca47
docs: add ADR directory with viewport-aware downsampling decision record
tylerkron Apr 11, 2026
4f4898e
docs: trim CLAUDE.md plot section to concise gotchas with ADR pointer
tylerkron Apr 11, 2026
7a1a85a
fix: address Qodo review — stream samples, reduce cap, add Dispose
tylerkron Apr 11, 2026
a90e38f
fix: address remaining Qodo review items (4-6)
tylerkron Apr 11, 2026
8c5b8db
perf: two-phase progressive loading for large sessions
tylerkron Apr 12, 2026
700e52d
perf: replace full data scan with sampled index seeks for Phase 2
tylerkron Apr 12, 2026
79262b3
feat: on-demand DB fetch for full-resolution data when zoomed in
tylerkron Apr 12, 2026
7000ef8
perf: smooth 60fps minimap drag with deferred high-fidelity DB fetch
tylerkron Apr 12, 2026
b6755ce
fix: address review issues — sparse check, axis indices, mouse-up cle…
tylerkron Apr 12, 2026
243b144
perf: async sampled-seek DB fetch with visual refining indicator
tylerkron Apr 12, 2026
8f70685
fix: reset axes when switching sessions so new session shows full extent
tylerkron Apr 12, 2026
f714fd7
docs: update ADR and CLAUDE.md to reflect async fetch and progressive…
tylerkron Apr 12, 2026
149f6a1
fix: address Qodo review — stale fetch, sampling tail, minimap axes, …
tylerkron Apr 13, 2026
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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ When working on:
- **Firewall/Network**: Ensure admin privileges handled properly, verify ports match
- **WiFi Discovery Issues**: Check network interface detection and port configuration
- **Manual IP Connections**: Ensure TCP port matches what device discovery reports
- **Plot/Minimap changes**: Read the "Plot Rendering (OxyPlot)" section below — there are non-obvious gotchas with `InvalidatePlot`, auto-range, and feedback loops. Key files: `DatabaseLogger.cs`, `MinimapInteractionController.cs`, `MinMaxDownsampler.cs`
- **New features**: Add unit tests with 80% coverage minimum

## Performance Considerations
Expand All @@ -252,6 +253,18 @@ When working on:
- Use dependency injection for testability
- Cache expensive operations

### Plot Rendering (OxyPlot)

The logged data viewer uses viewport-aware downsampling with progressive loading for 60fps interaction with large datasets. See [ADR 001](docs/adr/001-viewport-aware-downsampling.md) for full context. Key gotchas when modifying plot code:

- **`InvalidatePlot(true)` vs `(false)`**: Use `true` whenever `ItemsSource` or its underlying list has changed — `false` renders stale cached data
- **Don't use `ResetAllAxes()` with downsampled data**: Auto-range reads from `ItemsSource`, which may have shifted X boundaries. Use explicit `axis.Zoom(min, max)` from source data
- **Guard flag for minimap sync**: Always set `IsSyncingFromMinimap` before programmatic axis changes to prevent feedback loops
- **Reuse cached lists**: Don't allocate new `List<DataPoint>` per frame — use `_downsampledCache`
- **High-fidelity DB fetch is async**: `FetchViewportDataFromDb` runs on a background thread. Cancel in-flight fetches via `_fetchCts` before starting new ones. Results marshal back to UI via `Dispatcher.Invoke`
- **Drag vs settle pattern**: During continuous interaction (minimap drag, main plot pan), use only in-memory sampled data (`highFidelity: false`). DB fetches happen on mouse-up or after 200ms idle (`highFidelity: true`)
- **Session switching must reset axes**: `ClearPlot()` calls `axis.Reset()` on all axes. Without this, the new session inherits the previous session's zoom range

## Error Handling

- Use try-catch at appropriate levels
Expand Down
208 changes: 208 additions & 0 deletions Daqifi.Desktop.Test/Helpers/MinMaxDownsamplerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
using Daqifi.Desktop.Helpers;
using OxyPlot;

namespace Daqifi.Desktop.Test.Helpers;

[TestClass]
public class MinMaxDownsamplerTests
{
#region Downsample Tests

[TestMethod]
public void Downsample_EmptyList_ReturnsEmpty()
{
var result = MinMaxDownsampler.Downsample(new List<DataPoint>(), 10);
Assert.AreEqual(0, result.Count);
}

[TestMethod]
public void Downsample_FewPointsBelowThreshold_ReturnsAll()
{
var points = new List<DataPoint>
{
new(0, 1), new(1, 2), new(2, 3)
};
var result = MinMaxDownsampler.Downsample(points, 10);
Assert.AreEqual(3, result.Count);
}

[TestMethod]
public void Downsample_LargeDataset_ProducesCorrectSize()
{
var points = new List<DataPoint>();
for (var i = 0; i < 10000; i++)
{
points.Add(new DataPoint(i, Math.Sin(i * 0.01)));
}

var result = MinMaxDownsampler.Downsample(points, 100);
Assert.IsTrue(result.Count <= 200);
Assert.IsTrue(result.Count > 0);
}

[TestMethod]
public void Downsample_PreservesMinMax()
{
var points = new List<DataPoint>();
for (var i = 0; i < 10000; i++)
{
points.Add(new DataPoint(i, Math.Sin(i * 0.01)));
}

var result = MinMaxDownsampler.Downsample(points, 100);
var resultMax = result.Max(p => p.Y);
var resultMin = result.Min(p => p.Y);
var sourceMax = points.Max(p => p.Y);
var sourceMin = points.Min(p => p.Y);

Assert.IsTrue(Math.Abs(resultMax - sourceMax) < 0.05, "Should preserve approximate max");
Assert.IsTrue(Math.Abs(resultMin - sourceMin) < 0.05, "Should preserve approximate min");
}

#endregion

#region Sub-range Downsample Tests

[TestMethod]
public void Downsample_SubRange_OperatesOnCorrectRange()
{
var points = new List<DataPoint>();
for (var i = 0; i < 1000; i++)
{
points.Add(new DataPoint(i, i < 500 ? 0 : 100));
}

// Downsample only the second half (where values are 100)
var result = MinMaxDownsampler.Downsample(points, 500, 1000, 50);
Assert.IsTrue(result.All(p => p.Y == 100), "All downsampled points from second half should be 100");
}

[TestMethod]
public void Downsample_SubRange_EmptyRange_ReturnsEmpty()
{
var points = new List<DataPoint> { new(0, 1), new(1, 2) };
var result = MinMaxDownsampler.Downsample(points, 0, 0, 10);
Assert.AreEqual(0, result.Count);
}

[TestMethod]
public void Downsample_SubRange_SmallRange_ReturnsAll()
{
var points = new List<DataPoint>();
for (var i = 0; i < 100; i++)
{
points.Add(new DataPoint(i, i));
}

// Sub-range of 5 points with bucket count of 10 — should return all 5
var result = MinMaxDownsampler.Downsample(points, 10, 15, 10);
Assert.AreEqual(5, result.Count);
}

#endregion

#region FindVisibleRange Tests

[TestMethod]
public void FindVisibleRange_EmptyList_ReturnsZeroRange()
{
var (start, end) = MinMaxDownsampler.FindVisibleRange(new List<DataPoint>(), 0, 10);
Assert.AreEqual(0, start);
Assert.AreEqual(0, end);
}

[TestMethod]
public void FindVisibleRange_AllVisible_ReturnsFullRange()
{
var points = new List<DataPoint>
{
new(0, 0), new(1, 1), new(2, 2), new(3, 3), new(4, 4)
};

var (start, end) = MinMaxDownsampler.FindVisibleRange(points, -1, 5);
Assert.AreEqual(0, start);
Assert.AreEqual(5, end);
}

[TestMethod]
public void FindVisibleRange_MiddleSection_ReturnsPaddedRange()
{
var points = new List<DataPoint>();
for (var i = 0; i < 100; i++)
{
points.Add(new DataPoint(i, i));
}

var (start, end) = MinMaxDownsampler.FindVisibleRange(points, 30, 60);

// Should include padding: one point before 30 and one point after 60
Assert.IsTrue(start <= 29, $"Start ({start}) should be at or before index 29");
Assert.IsTrue(end >= 62, $"End ({end}) should be at or after index 62");
}

[TestMethod]
public void FindVisibleRange_NoPointsInRange_IncludesAdjacentPoints()
{
var points = new List<DataPoint>
{
new(0, 0), new(1, 1), new(10, 10), new(11, 11)
};

// Range 5-8 has no points; binary search lands between index 1 (X=1) and 2 (X=10)
// With padding: start backs up 1 from index 2 → 1, end advances 1 from index 2 → 3
var (start, end) = MinMaxDownsampler.FindVisibleRange(points, 5, 8);
Assert.AreEqual(1, start, "Should include point at X=1 (one before gap)");
Assert.AreEqual(3, end, "Should include point at X=10 (one after gap)");
}

[TestMethod]
public void FindVisibleRange_AtBoundaries_ClampsCorrectly()
{
var points = new List<DataPoint>
{
new(0, 0), new(1, 1), new(2, 2)
};

// Range starts before data
var (start, _) = MinMaxDownsampler.FindVisibleRange(points, -10, 1);
Assert.AreEqual(0, start, "Start should be clamped to 0");

// Range ends after data
var (_, end) = MinMaxDownsampler.FindVisibleRange(points, 1, 100);
Assert.AreEqual(3, end, "End should be clamped to list length");
}

[TestMethod]
public void FindVisibleRange_SinglePoint_ReturnsIt()
{
var points = new List<DataPoint> { new(5, 10) };

var (start, end) = MinMaxDownsampler.FindVisibleRange(points, 0, 10);
Assert.AreEqual(0, start);
Assert.AreEqual(1, end);
}

[TestMethod]
public void FindVisibleRange_LargeDataset_Performance()
{
var points = new List<DataPoint>();
for (var i = 0; i < 1_000_000; i++)
{
points.Add(new DataPoint(i, Math.Sin(i * 0.001)));
}

var sw = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < 1000; i++)
{
MinMaxDownsampler.FindVisibleRange(points, 400000, 600000);
}
sw.Stop();

// 1000 binary searches on 1M points should be well under 1 second,
// even on slow CI runners. Typical desktop: < 10ms.
Assert.IsTrue(sw.ElapsedMilliseconds < 1000,
$"1000 binary searches took {sw.ElapsedMilliseconds}ms, expected < 1000ms");
}

#endregion
}
Loading
Loading