using System; using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using SkiaSharp; namespace HC_APTBS.ViewModels { /// /// Reusable ViewModel for a single real-time scrolling line chart. /// Backed by LiveChartsCore with a fixed-width sample window. /// /// Two modes: /// /// Index-axis (default): of doubles; X = array index; /// axis is static. Samples shuffle through fixed X slots on each . /// Smooth-scroll (opt-in): of /// with X on a monotonic sample-index timeline. A host-driven per-frame hook calls /// to slide the X-axis window, producing true continuous /// leftward motion independent of data cadence. /// /// public sealed partial class SingleFlowChartViewModel : ObservableObject { private const int DefaultMaxSamples = 200; private const int SmoothTrimMargin = 4; private readonly bool _smoothScroll; private readonly int _maxSamples; // Index-axis mode storage. private readonly ObservableCollection? _values; // Smooth-scroll mode storage + timeline state. // The series holds N committed points at integer X positions plus one trailing // "endpoint" whose X/Y interpolate each render frame from the previous committed // point toward the pending sample. On each new sample the endpoint snaps to its // target (becoming committed) and a new endpoint is appended at the same spot. // Net effect: the line tip slides continuously instead of hopping +1 in X per sample. private readonly ObservableCollection? _points; private long _nextCommitX; private double _prevCommittedValue; private double _pendingValue; private DateTime _pendingArrivalUtc; private int _sampleCount; private ObservablePoint? _endpoint; /// Chart title label. [ObservableProperty] private string _title = string.Empty; /// Most recent sample value, displayed as a numeric label alongside the chart. [ObservableProperty] private double _currentValue; /// Series array bound to the CartesianChart. public ISeries[] Series { get; } /// X axes for the chart (auto-scrolling, no labels). public Axis[] XAxes { get; } /// Y axes for the chart. public Axis[] YAxes { get; } /// /// Tolerance band sections overlaid on the chart. /// Updated when is called. /// [ObservableProperty] private RectangularSection[] _sections = Array.Empty(); /// /// Creates a new chart ViewModel. /// /// Display title for the chart. /// SKColor for the line series. /// Maximum number of samples before the oldest is dropped. /// /// When true, store samples on a monotonic X timeline and expect the host to call /// each render frame to slide the visible window. /// When false (default), use the legacy index-axis behavior. /// public SingleFlowChartViewModel(string title, SKColor lineColor, int maxSamples = DefaultMaxSamples, bool smoothScroll = false) { _title = title; _maxSamples = maxSamples; _smoothScroll = smoothScroll; if (smoothScroll) { _points = new ObservableCollection(); Series = new ISeries[] { new LineSeries { Values = _points, Fill = null, GeometrySize = 0, Stroke = new SolidColorPaint(lineColor, 2), LineSmoothness = 0, AnimationsSpeed = TimeSpan.Zero } }; XAxes = new Axis[] { new Axis { IsVisible = false, AnimationsSpeed = TimeSpan.Zero, MinLimit = 0, MaxLimit = maxSamples } }; } else { _values = new ObservableCollection(); Series = new ISeries[] { new LineSeries { Values = _values, Fill = null, GeometrySize = 0, Stroke = new SolidColorPaint(lineColor, 2), LineSmoothness = 0, AnimationsSpeed = TimeSpan.Zero } }; XAxes = new Axis[] { new Axis { IsVisible = false, AnimationsSpeed = TimeSpan.Zero } }; } YAxes = new Axis[] { new Axis { AnimationsSpeed = TimeSpan.Zero, MinLimit = 0, SeparatorsPaint = new SolidColorPaint(new SKColor(224, 224, 224), 0.75f) } }; } /// /// Appends a value to the chart. Drops the oldest value when the window is full. /// Must be called on the UI thread. /// public void AddValue(double value) { CurrentValue = value; if (_smoothScroll) { _sampleCount++; if (_sampleCount == 1) { // First sample — commit at X=0 and anchor the viewport so the point sits // at the right edge. No endpoint yet; we need two samples before we can // interpolate between previous and pending. _points!.Add(new ObservablePoint(0, value)); _nextCommitX = 1; _prevCommittedValue = value; _pendingValue = value; _pendingArrivalUtc = DateTime.UtcNow; XAxes[0].MaxLimit = 0; XAxes[0].MinLimit = -_maxSamples; } else if (_sampleCount == 2) { // Second sample — create the sliding endpoint at the previous committed // point. UpdateViewport will interpolate it toward the pending target. _pendingValue = value; _pendingArrivalUtc = DateTime.UtcNow; _endpoint = new ObservablePoint(_nextCommitX - 1, _prevCommittedValue); _points!.Add(_endpoint); } else { // Third+ sample — finalize the existing endpoint at its target (making // it a committed point), advance the commit index, then append a fresh // endpoint at the just-finalized position. Because the endpoint is // already at X = _nextCommitX after the last render frame with // fraction≈1, the viewport continues from the same position without // a visible hop. _endpoint!.X = _nextCommitX; _endpoint.Y = _pendingValue; _prevCommittedValue = _pendingValue; _nextCommitX++; _pendingValue = value; _pendingArrivalUtc = DateTime.UtcNow; _endpoint = new ObservablePoint(_nextCommitX - 1, _prevCommittedValue); _points!.Add(_endpoint); // Keep a small margin past the visible window so the leftmost point // doesn't pop as the viewport advances past the trim boundary. while (_points.Count > _maxSamples + SmoothTrimMargin) _points.RemoveAt(0); } } else { _values!.Add(value); if (_values.Count > _maxSamples) _values.RemoveAt(0); } } /// /// Smooth-scroll mode only: slides the X-axis viewport so the rightmost visible edge /// drifts continuously past the most recent sample, producing frame-rate smooth motion /// independent of data cadence. Host calls this once per render frame (e.g. from /// CompositionTarget.Rendering). /// /// Current time; pass . /// /// Expected inter-sample period in milliseconds (e.g. 1000/Hz). Used to interpolate /// fractional progress between samples. /// public void UpdateViewport(DateTime nowUtc, double nominalPeriodMs) { if (!_smoothScroll || _endpoint == null || nominalPeriodMs <= 0) return; double fraction = (nowUtc - _pendingArrivalUtc).TotalMilliseconds / nominalPeriodMs; if (fraction < 0) fraction = 0; else if (fraction > 1) fraction = 1; double slideX = (_nextCommitX - 1) + fraction; double slideY = _prevCommittedValue + (_pendingValue - _prevCommittedValue) * fraction; _endpoint.X = slideX; _endpoint.Y = slideY; XAxes[0].MaxLimit = slideX; XAxes[0].MinLimit = slideX - _maxSamples; } /// /// Sets the tolerance band (displayed as a shaded region on the chart). /// /// Center value of the tolerance band. /// Half-width of the tolerance band. public void SetTolerance(double target, double tolerance) { Sections = new[] { new RectangularSection { Yi = target - tolerance, Yj = target + tolerance, Fill = new SolidColorPaint(SKColors.LimeGreen.WithAlpha(40)) } }; } /// /// Clears all sample data and tolerance bands. Resets the smooth-scroll timeline. /// public void Clear() { if (_smoothScroll) { _points!.Clear(); _nextCommitX = 0; _prevCommittedValue = 0; _pendingValue = 0; _sampleCount = 0; _endpoint = null; XAxes[0].MinLimit = 0; XAxes[0].MaxLimit = _maxSamples; } else { _values!.Clear(); } Sections = Array.Empty(); } } }