From 09940aaf0c468d39f5d87b8877d273bab7313027 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:14:25 +0700 Subject: [PATCH 01/88] P1D.3: add interaction performance helpers --- .../ComtradeInteractionPerformanceMath.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 Services/ComtradeInteractionPerformanceMath.cs diff --git a/Services/ComtradeInteractionPerformanceMath.cs b/Services/ComtradeInteractionPerformanceMath.cs new file mode 100644 index 000000000..a7d05dcd2 --- /dev/null +++ b/Services/ComtradeInteractionPerformanceMath.cs @@ -0,0 +1,66 @@ +namespace ArIED61850Tester.Services; + +internal static class ComtradeInteractionPerformanceMath +{ + internal static int NearestSortedIndex(IReadOnlyList sortedValues, double target) + { + if (sortedValues is null || sortedValues.Count == 0 || !double.IsFinite(target)) + return -1; + + var low = 0; + var high = sortedValues.Count - 1; + while (low <= high) + { + var mid = low + ((high - low) >> 1); + var value = sortedValues[mid]; + if (value < target) + low = mid + 1; + else if (value > target) + high = mid - 1; + else + return mid; + } + + if (low <= 0) return 0; + if (low >= sortedValues.Count) return sortedValues.Count - 1; + return Math.Abs(sortedValues[low] - target) < Math.Abs(sortedValues[low - 1] - target) + ? low + : low - 1; + } + + internal static bool TrySnapSorted( + IReadOnlyList sortedValues, + double target, + double tolerance, + out double snapped) + { + snapped = target; + if (tolerance <= 0 || !double.IsFinite(tolerance)) + return false; + + var index = NearestSortedIndex(sortedValues, target); + if (index < 0) + return false; + + var candidate = sortedValues[index]; + if (Math.Abs(candidate - target) > tolerance) + return false; + + snapped = candidate; + return true; + } + + internal static int LabelStride(int itemCount, double plotWidth, double minimumPixelsPerLabel) + { + if (itemCount <= 1) return 1; + if (!double.IsFinite(plotWidth) || plotWidth <= 0) return itemCount; + var capacity = Math.Max(1, (int)Math.Floor(plotWidth / Math.Max(1.0, minimumPixelsPerLabel))); + return Math.Max(1, (int)Math.Ceiling(itemCount / (double)capacity)); + } + + internal static bool ShouldDrawTransitionGlyph( + double x, + double lastGlyphX, + double minimumSpacingPixels) + => double.IsFinite(x) && (!double.IsFinite(lastGlyphX) || x - lastGlyphX >= minimumSpacingPixels); +} From 710aa6d52ee12ce5a3c416f117c196d0bc0cf1b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:16:08 +0700 Subject: [PATCH 02/88] P1D.3: add cached disturbance interaction renderer --- Controls/ComtradeDisturbanceViewP1D3.cs | 804 ++++++++++++++++++++++++ 1 file changed, 804 insertions(+) create mode 100644 Controls/ComtradeDisturbanceViewP1D3.cs diff --git a/Controls/ComtradeDisturbanceViewP1D3.cs b/Controls/ComtradeDisturbanceViewP1D3.cs new file mode 100644 index 000000000..57ee6a3ba --- /dev/null +++ b/Controls/ComtradeDisturbanceViewP1D3.cs @@ -0,0 +1,804 @@ +using System.Diagnostics; +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester.Controls; + +/// +/// P1D.3 workstation renderer. Heavy waveform/digital geometry is cached separately from the +/// lightweight cursor overlay so C1/C2 movement never rebuilds all visible tracks. Pan gestures +/// use a translated preview and commit the time window only once on mouse-up. +/// +public sealed class ComtradeDisturbanceViewP1D3 : FrameworkElement +{ + private const double LabelWidth = 150.0; + private const double RightMargin = 18.0; + private const double TopMargin = 14.0; + private const double BottomAxisHeight = 34.0; + private const double AnalogTrackHeight = 92.0; + private const double DigitalTrackHeight = 38.0; + private const double TrackGap = 5.0; + private const double CursorHitRadius = 9.0; + private const double PanActivationPixels = 4.0; + private const double TransitionGlyphSpacing = 28.0; + private static readonly long InteractiveNotifyIntervalTicks = Math.Max(1, Stopwatch.Frequency / 30); + + private enum PointerMode + { + None, + PendingPan, + Panning, + Cursor1, + Cursor2 + } + + private IReadOnlyList _tracks = Array.Empty(); + private double[] _snapTimesMilliseconds = Array.Empty(); + private double _timeMultiplier = 1.0; + private double? _triggerMilliseconds; + private double _nominalFrequencyHz; + private double _fullStartMilliseconds; + private double _fullEndMilliseconds; + private double _viewStartMilliseconds; + private double _viewEndMilliseconds; + private double? _cursor1Milliseconds; + private double? _cursor2Milliseconds; + private Rect _lastPlot; + private Rect _lastTimelinePlot; + private PointerMode _pointerMode; + private Point _pointerStartPoint; + private double _panStartMilliseconds; + private double _panEndMilliseconds; + private double _panPreviewPixels; + private long _lastInteractiveNotifyTicks; + + private DrawingGroup? _frameLayer; + private DrawingGroup? _dataLayer; + private Size _cachedSize; + private bool _staticDirty = true; + + internal event EventHandler? NavigationChanged; + internal event EventHandler? CursorChanged; + internal event EventHandler? PanRequested; + + internal double? Cursor1Milliseconds => _cursor1Milliseconds; + internal double? Cursor2Milliseconds => _cursor2Milliseconds; + internal double? CursorAMilliseconds => _cursor1Milliseconds; + internal double? CursorBMilliseconds => _cursor2Milliseconds; + internal double ViewStartMilliseconds => _viewStartMilliseconds; + internal double ViewEndMilliseconds => _viewEndMilliseconds; + internal double FullStartMilliseconds => _fullStartMilliseconds; + internal double FullEndMilliseconds => _fullEndMilliseconds; + + public ComtradeDisturbanceViewP1D3() + { + Focusable = true; + Cursor = Cursors.Cross; + ToolTip = "Wheel: scroll signals • Ctrl+wheel: zoom • Drag plot: pan • Drag C1/C2: move • Right-click: C2 • cursors snap to digital edges"; + } + + internal void ShowTracks( + IReadOnlyList tracks, + double timeMultiplier, + double? triggerMilliseconds, + bool preserveCursor = true) + { + _tracks = tracks ?? Array.Empty(); + _timeMultiplier = timeMultiplier > 0 && double.IsFinite(timeMultiplier) ? timeMultiplier : 1.0; + _triggerMilliseconds = triggerMilliseconds is { } trigger && double.IsFinite(trigger) ? trigger : null; + _snapTimesMilliseconds = BuildSnapIndex(_tracks); + + var bounds = GetTimeBounds(_tracks); + _fullStartMilliseconds = bounds.Start; + _fullEndMilliseconds = bounds.End; + _viewStartMilliseconds = bounds.Start; + _viewEndMilliseconds = bounds.End; + if (!preserveCursor) + { + _cursor1Milliseconds = null; + _cursor2Milliseconds = null; + } + + Height = Math.Max(330, TopMargin + BottomAxisHeight + _tracks.Sum(track => TrackHeight(track) + TrackGap)); + MarkStaticDirty(); + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void ShowMessage(string message) + { + _tracks = Array.Empty(); + _snapTimesMilliseconds = Array.Empty(); + _fullStartMilliseconds = 0; + _fullEndMilliseconds = 0; + _viewStartMilliseconds = 0; + _viewEndMilliseconds = 0; + _cursor1Milliseconds = null; + _cursor2Milliseconds = null; + Height = 330; + ToolTip = message; + MarkStaticDirty(); + InvalidateVisual(); + } + + internal void ApplyTriggerFocusedDefault(double nominalFrequencyHz) + { + _nominalFrequencyHz = nominalFrequencyHz; + if (_fullEndMilliseconds <= _fullStartMilliseconds) + return; + + var window = ComtradeTimeSignalsNavigationMath.CreateTriggerFocusedWindow( + _fullStartMilliseconds, + _fullEndMilliseconds, + _triggerMilliseconds, + nominalFrequencyHz); + SetView(window.StartMilliseconds, window.EndMilliseconds); + + var cursors = ComtradeTimeSignalsNavigationMath.CreateInitialCursors( + _fullStartMilliseconds, + _fullEndMilliseconds, + _triggerMilliseconds, + nominalFrequencyHz); + _cursor1Milliseconds = cursors.Cursor1Milliseconds; + _cursor2Milliseconds = cursors.Cursor2Milliseconds; + MarkStaticDirty(); + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void ResetToTriggerView() + { + if (_nominalFrequencyHz > 0) + { + ApplyTriggerFocusedDefault(_nominalFrequencyHz); + return; + } + ResetNavigation(); + } + + internal void SetCursorAFromAbsoluteMilliseconds(double milliseconds) + => SetCursorFromHost(ComtradeDisturbanceCursor.Cursor1, milliseconds); + + internal void SetCursorFromHost(ComtradeDisturbanceCursor cursor, double milliseconds) + { + if (!double.IsFinite(milliseconds)) return; + if (cursor == ComtradeDisturbanceCursor.Cursor1) + _cursor1Milliseconds = milliseconds; + else + _cursor2Milliseconds = milliseconds; + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void ResetNavigation() + { + if (_fullEndMilliseconds <= _fullStartMilliseconds) return; + _viewStartMilliseconds = _fullStartMilliseconds; + _viewEndMilliseconds = _fullEndMilliseconds; + MarkStaticDirty(); + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void SetViewWindow(double startMilliseconds, double endMilliseconds) + { + SetView(startMilliseconds, endMilliseconds); + MarkStaticDirty(); + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal double PlotFractionAt(double x) + { + if (_lastPlot.Width <= 0) return 0; + return Math.Clamp((x - _lastPlot.Left) / _lastPlot.Width, 0.0, 1.0); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + var bounds = new Rect(0, 0, Math.Max(0, ActualWidth), Math.Max(0, ActualHeight)); + dc.DrawRectangle(Brushes.White, null, bounds); + if (bounds.Width < 320 || bounds.Height < 160) return; + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var body = new Typeface("Segoe UI"); + var semibold = new Typeface("Segoe UI Semibold"); + var plotWidth = Math.Max(80, bounds.Width - LabelWidth - RightMargin); + _lastPlot = new Rect(LabelWidth, TopMargin, plotWidth, Math.Max(80, bounds.Height - TopMargin - BottomAxisHeight)); + + if (_tracks.Count == 0 || _viewEndMilliseconds <= _viewStartMilliseconds) + { + DrawText(dc, "Select signals from the left panel to build a synchronized disturbance timeline.", 12, + body, Color.FromRgb(119, 133, 151), new Point(LabelWidth + 18, 42), dpi); + return; + } + + EnsureCachedLayers(bounds, dpi, body, semibold); + if (_frameLayer is not null) + dc.DrawDrawing(_frameLayer); + + if (_dataLayer is not null) + { + if (_pointerMode == PointerMode.Panning && Math.Abs(_panPreviewPixels) > 0.1) + { + dc.PushClip(new RectangleGeometry(_lastTimelinePlot)); + dc.PushTransform(new TranslateTransform(_panPreviewPixels, 0)); + dc.DrawDrawing(_dataLayer); + dc.Pop(); + dc.Pop(); + } + else + { + dc.DrawDrawing(_dataLayer); + } + } + + var cursorOffset = _pointerMode == PointerMode.Panning ? _panPreviewPixels : 0.0; + DrawCursor(dc, _lastTimelinePlot, _cursor1Milliseconds, "C1", Color.FromRgb(221, 142, 32), dpi, semibold, cursorOffset); + DrawCursor(dc, _lastTimelinePlot, _cursor2Milliseconds, "C2", Color.FromRgb(36, 172, 211), dpi, semibold, cursorOffset); + } + + protected override void OnMouseWheel(MouseWheelEventArgs e) + { + base.OnMouseWheel(e); + if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return; + if (!_lastPlot.Contains(e.GetPosition(this)) || _viewEndMilliseconds <= _viewStartMilliseconds) return; + + var span = _viewEndMilliseconds - _viewStartMilliseconds; + var fraction = PlotFractionAt(e.GetPosition(this).X); + var anchor = _viewStartMilliseconds + span * fraction; + var factor = e.Delta > 0 ? 0.72 : 1.38; + var nextSpan = Math.Clamp(span * factor, MinimumViewSpan(), Math.Max(MinimumViewSpan(), _fullEndMilliseconds - _fullStartMilliseconds)); + var start = anchor - nextSpan * fraction; + SetView(start, start + nextSpan); + MarkStaticDirty(); + InvalidateVisual(); + RaiseNavigationChanged(); + e.Handled = true; + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + var point = e.GetPosition(this); + if (!_lastPlot.Contains(point) || _viewEndMilliseconds <= _viewStartMilliseconds) return; + + Focus(); + if (e.ClickCount >= 2 && e.ChangedButton == MouseButton.Left) + { + ResetToTriggerView(); + e.Handled = true; + return; + } + + if (e.ChangedButton == MouseButton.Right) + { + PlaceCursor(ComtradeDisturbanceCursor.Cursor2, TimeAtFraction(PlotFractionAt(point.X)), true); + e.Handled = true; + return; + } + + if (e.ChangedButton == MouseButton.Left) + { + _pointerStartPoint = point; + _pointerMode = IsNearCursor(point.X, _cursor1Milliseconds) + ? PointerMode.Cursor1 + : IsNearCursor(point.X, _cursor2Milliseconds) + ? PointerMode.Cursor2 + : PointerMode.PendingPan; + _panStartMilliseconds = _viewStartMilliseconds; + _panEndMilliseconds = _viewEndMilliseconds; + _panPreviewPixels = 0; + CaptureMouse(); + Cursor = _pointerMode is PointerMode.Cursor1 or PointerMode.Cursor2 ? Cursors.SizeWE : Cursors.Hand; + e.Handled = true; + return; + } + + if (e.ChangedButton == MouseButton.Middle) + { + _pointerStartPoint = point; + _pointerMode = PointerMode.Panning; + _panStartMilliseconds = _viewStartMilliseconds; + _panEndMilliseconds = _viewEndMilliseconds; + _panPreviewPixels = 0; + CaptureMouse(); + Cursor = Cursors.Hand; + e.Handled = true; + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (_pointerMode == PointerMode.None || !IsMouseCaptured || _lastPlot.Width <= 0) + { + Cursor = HoverCursor(e.GetPosition(this)); + return; + } + + var point = e.GetPosition(this); + if (_pointerMode is PointerMode.Cursor1 or PointerMode.Cursor2) + { + var cursor = _pointerMode == PointerMode.Cursor1 + ? ComtradeDisturbanceCursor.Cursor1 + : ComtradeDisturbanceCursor.Cursor2; + PlaceCursor(cursor, TimeAtFraction(PlotFractionAt(point.X)), false); + e.Handled = true; + return; + } + + var deltaPixels = point.X - _pointerStartPoint.X; + if (_pointerMode == PointerMode.PendingPan && Math.Abs(deltaPixels) >= PanActivationPixels) + _pointerMode = PointerMode.Panning; + if (_pointerMode != PointerMode.Panning) return; + + _panPreviewPixels = deltaPixels; + InvalidateVisual(); + e.Handled = true; + } + + protected override void OnMouseUp(MouseButtonEventArgs e) + { + base.OnMouseUp(e); + if (_pointerMode == PointerMode.None) return; + + var mode = _pointerMode; + var point = e.GetPosition(this); + _pointerMode = PointerMode.None; + if (IsMouseCaptured) ReleaseMouseCapture(); + Cursor = Cursors.Cross; + + if (mode is PointerMode.Cursor1 or PointerMode.Cursor2) + { + var cursor = mode == PointerMode.Cursor1 + ? ComtradeDisturbanceCursor.Cursor1 + : ComtradeDisturbanceCursor.Cursor2; + PlaceCursor(cursor, TimeAtFraction(PlotFractionAt(point.X)), true); + } + else if (mode == PointerMode.PendingPan && e.ChangedButton == MouseButton.Left) + { + PlaceCursor(ComtradeDisturbanceCursor.Cursor1, TimeAtFraction(PlotFractionAt(point.X)), true); + } + else if (mode == PointerMode.Panning && _lastPlot.Width > 0) + { + var deltaPixels = point.X - _pointerStartPoint.X; + var span = _panEndMilliseconds - _panStartMilliseconds; + var delta = -deltaPixels / _lastPlot.Width * span; + SetView(_panStartMilliseconds + delta, _panEndMilliseconds + delta); + _panPreviewPixels = 0; + MarkStaticDirty(); + InvalidateVisual(); + RaiseNavigationChanged(); + + var deltaFraction = -deltaPixels / _lastPlot.Width; + if (Math.Abs(deltaFraction) > 1e-6) + PanRequested?.Invoke(this, new ComtradeDisturbancePanRequestedEventArgs(deltaFraction)); + } + + e.Handled = true; + } + + protected override void OnLostMouseCapture(MouseEventArgs e) + { + base.OnLostMouseCapture(e); + if (_pointerMode == PointerMode.Panning && Math.Abs(_panPreviewPixels) > 0.1) + { + _panPreviewPixels = 0; + InvalidateVisual(); + } + _pointerMode = PointerMode.None; + Cursor = Cursors.Cross; + } + + private void EnsureCachedLayers(Rect bounds, double dpi, Typeface body, Typeface semibold) + { + var size = new Size(bounds.Width, bounds.Height); + if (!_staticDirty && _frameLayer is not null && _dataLayer is not null && _cachedSize == size) + return; + + var frame = new DrawingGroup(); + var data = new DrawingGroup(); + var plotWidth = Math.Max(80, bounds.Width - LabelWidth - RightMargin); + var y = TopMargin; + + using (var frameDc = frame.Open()) + using (var dataDc = data.Open()) + { + foreach (var track in _tracks) + { + var height = TrackHeight(track); + var row = new Rect(0, y, bounds.Width, height); + var plot = new Rect(LabelWidth, y, plotWidth, height); + DrawTrackBackground(frameDc, row, plot); + DrawTrackLabel(frameDc, track, row, dpi, body, semibold); + if (track.IsDigital) + DrawDigitalTrack(dataDc, track, plot, dpi, body); + else + DrawAnalogTrack(dataDc, track, plot); + y += height + TrackGap; + } + + var tracksBottom = Math.Min(bounds.Height - BottomAxisHeight, y - TrackGap); + _lastTimelinePlot = new Rect(LabelWidth, TopMargin, plotWidth, Math.Max(1, tracksBottom - TopMargin)); + DrawTrigger(dataDc, _lastTimelinePlot, dpi, semibold); + DrawTimeAxis(frameDc, new Rect(LabelWidth, tracksBottom, plotWidth, BottomAxisHeight), dpi, body, semibold); + } + + frame.Freeze(); + data.Freeze(); + _frameLayer = frame; + _dataLayer = data; + _cachedSize = size; + _staticDirty = false; + } + + private void PlaceCursor(ComtradeDisturbanceCursor cursor, double requestedMilliseconds, bool isFinal) + { + if (!double.IsFinite(requestedMilliseconds)) return; + requestedMilliseconds = Math.Clamp(requestedMilliseconds, _fullStartMilliseconds, _fullEndMilliseconds); + var tolerance = ComtradeTimeSignalsNavigationMath.SnapToleranceMilliseconds( + _viewEndMilliseconds - _viewStartMilliseconds, + Math.Max(1.0, _lastPlot.Width)); + var snapped = ComtradeInteractionPerformanceMath.TrySnapSorted( + _snapTimesMilliseconds, + requestedMilliseconds, + tolerance, + out var snappedMilliseconds); + var value = snapped ? snappedMilliseconds : requestedMilliseconds; + + if (cursor == ComtradeDisturbanceCursor.Cursor1) + _cursor1Milliseconds = value; + else + _cursor2Milliseconds = value; + + InvalidateVisual(); + if (isFinal || ShouldNotifyInteractive()) + { + RaiseNavigationChanged(); + CursorChanged?.Invoke(this, new ComtradeDisturbanceCursorChangedEventArgs(cursor, value, tolerance, isFinal, snapped)); + } + } + + private bool ShouldNotifyInteractive() + { + var now = Stopwatch.GetTimestamp(); + if (now - _lastInteractiveNotifyTicks < InteractiveNotifyIntervalTicks) return false; + _lastInteractiveNotifyTicks = now; + return true; + } + + private double[] BuildSnapIndex(IReadOnlyList tracks) + { + var times = new List(); + foreach (var track in tracks) + { + if (!track.IsDigital) continue; + if (track.DigitalEdges is { Count: > 0 }) + { + times.AddRange(track.DigitalEdges.Select(edge => ToMilliseconds(edge.Timestamp))); + continue; + } + + if (track.Digital is null) continue; + var count = Math.Min(track.Digital.Length, track.Timestamps.Length); + for (var i = 1; i < count; i++) + { + if ((track.Digital[i - 1] != 0) != (track.Digital[i] != 0)) + times.Add(ToMilliseconds(track.Timestamps[i])); + } + } + + if (times.Count == 0) return Array.Empty(); + times.Sort(); + var unique = new List(times.Count) { times[0] }; + for (var i = 1; i < times.Count; i++) + { + if (Math.Abs(times[i] - unique[^1]) > 1e-9) + unique.Add(times[i]); + } + return unique.ToArray(); + } + + private Cursor HoverCursor(Point point) + { + if (!_lastPlot.Contains(point)) return Cursors.Arrow; + if (IsNearCursor(point.X, _cursor1Milliseconds) || IsNearCursor(point.X, _cursor2Milliseconds)) + return Cursors.SizeWE; + return Cursors.Cross; + } + + private bool IsNearCursor(double x, double? time) + { + if (time is not { } milliseconds || _viewEndMilliseconds <= _viewStartMilliseconds || + milliseconds < _viewStartMilliseconds || milliseconds > _viewEndMilliseconds) + return false; + return Math.Abs(x - XForTime(milliseconds, _lastPlot)) <= CursorHitRadius; + } + + private void DrawTrackBackground(DrawingContext dc, Rect row, Rect plot) + { + dc.DrawRectangle(FrozenBrush(Color.FromRgb(252, 253, 255)), null, row); + dc.DrawLine(FrozenPen(Color.FromRgb(226, 232, 240), 1), new Point(0, row.Bottom), new Point(row.Right, row.Bottom)); + var gridPen = FrozenPen(Color.FromRgb(238, 242, 247), 1); + for (var i = 0; i <= 10; i++) + { + var x = plot.Left + plot.Width * i / 10.0; + dc.DrawLine(gridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + } + } + + private void DrawTrackLabel(DrawingContext dc, ComtradeDisturbanceTrack track, Rect row, double dpi, Typeface body, Typeface semibold) + { + dc.DrawRoundedRectangle(FrozenBrush(track.StrokeColor), null, new Rect(10, row.Top + 9, 4, Math.Max(14, row.Height - 18)), 2, 2); + DrawText(dc, track.Title, 10.5, semibold, Color.FromRgb(43, 61, 82), new Point(22, row.Top + 7), dpi, LabelWidth - 30); + + // Digital lanes are intentionally title-only: phase/circuit/normal-state metadata already + // exists in the Signals browser and event table, and repeating it here caused unreadable text noise. + if (track.IsDigital) return; + var subtitle = string.IsNullOrWhiteSpace(track.Units) + ? track.Subtitle + : string.IsNullOrWhiteSpace(track.Subtitle) ? track.Units : $"{track.Subtitle} • {track.Units}"; + if (!string.IsNullOrWhiteSpace(subtitle)) + DrawText(dc, subtitle, 8.6, body, Color.FromRgb(119, 132, 149), new Point(22, row.Top + 26), dpi, LabelWidth - 30); + } + + private void DrawAnalogTrack(DrawingContext dc, ComtradeDisturbanceTrack track, Rect plot) + { + if (track.Analog is null || track.Timestamps.Length == 0) return; + var count = Math.Min(track.Analog.Length, track.Timestamps.Length); + if (count <= 0) return; + + var min = double.PositiveInfinity; + var max = double.NegativeInfinity; + for (var i = 0; i < count; i++) + { + var ms = ToMilliseconds(track.Timestamps[i]); + if (ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) continue; + var value = track.Analog[i]; + if (!double.IsFinite(value)) continue; + min = Math.Min(min, value); + max = Math.Max(max, value); + } + if (!double.IsFinite(min) || !double.IsFinite(max)) return; + if (Math.Abs(max - min) < 1e-12) + { + var pad = Math.Max(1.0, Math.Abs(max) * 0.1); + min -= pad; + max += pad; + } + + if (min < 0 && max > 0) + { + var zeroY = plot.Bottom - (0 - min) / (max - min) * plot.Height; + dc.DrawLine(FrozenPen(Color.FromRgb(205, 214, 224), 1), new Point(plot.Left, zeroY), new Point(plot.Right, zeroY)); + } + + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + var maxPoints = Math.Max(120, (int)Math.Ceiling(plot.Width * 1.6)); + var stride = track.PreserveAllPoints ? 1 : Math.Max(1, count / maxPoints); + var started = false; + for (var i = 0; i < count; i += stride) + { + var ms = ToMilliseconds(track.Timestamps[i]); + if (ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) continue; + var value = track.Analog[i]; + if (!double.IsFinite(value)) continue; + var x = XForTime(ms, plot); + var y = plot.Bottom - (value - min) / (max - min) * plot.Height; + if (!started) + { + context.BeginFigure(new Point(x, y), false, false); + started = true; + } + else + { + context.LineTo(new Point(x, y), true, false); + } + } + } + geometry.Freeze(); + dc.DrawGeometry(null, FrozenPen(track.StrokeColor, 1.15), geometry); + } + + private void DrawDigitalTrack(DrawingContext dc, ComtradeDisturbanceTrack track, Rect plot, double dpi, Typeface body) + { + if (track.Digital is null || track.Timestamps.Length == 0) return; + var count = Math.Min(track.Digital.Length, track.Timestamps.Length); + if (count <= 0) return; + var mid = plot.Top + plot.Height * 0.5; + dc.DrawLine(FrozenPen(Color.FromRgb(216, 224, 233), 1), new Point(plot.Left, mid), new Point(plot.Right, mid)); + + var activeBrush = FrozenBrush(Color.FromArgb(42, track.StrokeColor.R, track.StrokeColor.G, track.StrokeColor.B)); + var pen = FrozenPen(track.StrokeColor, 1.25); + if (!track.DigitalIsLossy) + { + var previousRaw = track.Digital[0] != 0 ? 1 : 0; + var previousMs = ToMilliseconds(track.Timestamps[0]); + for (var i = 1; i <= count; i++) + { + var endMs = i < count ? ToMilliseconds(track.Timestamps[i]) : ToMilliseconds(track.Timestamps[count - 1]); + var clampedStart = Math.Max(previousMs, _viewStartMilliseconds); + var clampedEnd = Math.Min(endMs, _viewEndMilliseconds); + if (previousRaw != track.DigitalNormalState && clampedEnd > clampedStart) + { + var x1 = XForTime(clampedStart, plot); + var x2 = XForTime(clampedEnd, plot); + dc.DrawRectangle(activeBrush, null, new Rect(x1, plot.Top + 4, Math.Max(1, x2 - x1), plot.Height - 8)); + } + if (i >= count) break; + previousRaw = track.Digital[i] != 0 ? 1 : 0; + previousMs = endMs; + } + } + + var transitions = new List<(double Milliseconds, bool Rising)>(); + if (track.DigitalEdges is { Count: > 0 } edges) + { + foreach (var edge in edges) + { + var ms = ToMilliseconds(edge.Timestamp); + if (ms >= _viewStartMilliseconds && ms <= _viewEndMilliseconds) + transitions.Add((ms, edge.AfterState != 0)); + } + } + else + { + for (var i = 1; i < count; i++) + { + var before = track.Digital[i - 1] != 0; + var after = track.Digital[i] != 0; + if (before == after) continue; + var ms = ToMilliseconds(track.Timestamps[i]); + if (ms >= _viewStartMilliseconds && ms <= _viewEndMilliseconds) + transitions.Add((ms, after)); + } + } + + var pixelColumns = new HashSet(); + var sparseEnoughForGlyphs = transitions.Count <= Math.Max(2, (int)(plot.Width / TransitionGlyphSpacing)); + var lastGlyphX = double.NegativeInfinity; + foreach (var transition in transitions) + { + var x = XForTime(transition.Milliseconds, plot); + var pixel = (int)Math.Round(x); + if (pixelColumns.Add(pixel)) + dc.DrawLine(pen, new Point(x, plot.Top + 3), new Point(x, plot.Bottom - 3)); + + if (!sparseEnoughForGlyphs || + !ComtradeInteractionPerformanceMath.ShouldDrawTransitionGlyph(x, lastGlyphX, TransitionGlyphSpacing)) + continue; + DrawText(dc, transition.Rising ? "↑" : "↓", 9.5, body, track.StrokeColor, new Point(x + 2, plot.Top), dpi); + lastGlyphX = x; + } + } + + private void DrawTrigger(DrawingContext dc, Rect plot, double dpi, Typeface semibold) + { + if (_triggerMilliseconds is not { } trigger || trigger < _viewStartMilliseconds || trigger > _viewEndMilliseconds) return; + var x = XForTime(trigger, plot); + dc.DrawLine(FrozenDashedPen(Color.FromRgb(217, 121, 41), 1.1), new Point(x, plot.Top), new Point(x, plot.Bottom)); + DrawText(dc, "TRG", 8.2, semibold, Color.FromRgb(186, 99, 31), new Point(x + 3, plot.Top + 17), dpi); + } + + private void DrawCursor(DrawingContext dc, Rect plot, double? time, string label, Color color, double dpi, Typeface semibold, double xOffset) + { + if (time is not { } ms || ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) return; + var x = XForTime(ms, plot) + xOffset; + if (x < plot.Left - 14 || x > plot.Right + 14) return; + var brush = FrozenBrush(color); + dc.DrawLine(FrozenPen(color, 1.2), new Point(x, plot.Top + 15), new Point(x, plot.Bottom)); + dc.DrawRoundedRectangle(brush, null, new Rect(x - 13, plot.Top, 26, 15), 3, 3); + DrawText(dc, label, 8.2, semibold, Colors.White, new Point(x - 8, plot.Top + 1), dpi); + } + + private void DrawTimeAxis(DrawingContext dc, Rect axis, double dpi, Typeface body, Typeface semibold) + { + var trigger = _triggerMilliseconds ?? 0.0; + dc.DrawLine(FrozenPen(Color.FromRgb(185, 196, 210), 1), new Point(axis.Left, axis.Top), new Point(axis.Right, axis.Top)); + var tickCount = Math.Clamp((int)Math.Round(axis.Width / 120.0), 5, 10); + for (var i = 0; i <= tickCount; i++) + { + var fraction = i / (double)tickCount; + var x = axis.Left + axis.Width * fraction; + var absolute = _viewStartMilliseconds + (_viewEndMilliseconds - _viewStartMilliseconds) * fraction; + var relative = absolute - trigger; + dc.DrawLine(FrozenPen(Color.FromRgb(196, 206, 218), 1), new Point(x, axis.Top), new Point(x, axis.Top + 4)); + var label = Math.Abs(relative) < 0.0005 ? "0" : relative.ToString("+0.###;-0.###", CultureInfo.CurrentCulture); + DrawText(dc, label, 8.1, body, Color.FromRgb(111, 125, 143), new Point(x - 15, axis.Top + 7), dpi); + } + DrawText(dc, "ms relative to trigger", 8.2, semibold, Color.FromRgb(104, 120, 140), new Point(axis.Right - 102, axis.Top + 21), dpi); + } + + private void SetView(double start, double end) + { + var fullSpan = _fullEndMilliseconds - _fullStartMilliseconds; + if (fullSpan <= 0) return; + var span = Math.Clamp(end - start, MinimumViewSpan(), fullSpan); + if (start < _fullStartMilliseconds) start = _fullStartMilliseconds; + if (start + span > _fullEndMilliseconds) start = _fullEndMilliseconds - span; + _viewStartMilliseconds = start; + _viewEndMilliseconds = start + span; + } + + private void MarkStaticDirty() + { + _staticDirty = true; + _frameLayer = null; + _dataLayer = null; + } + + private double MinimumViewSpan() => Math.Max(0.001, (_fullEndMilliseconds - _fullStartMilliseconds) / 5000.0); + private double TimeAtFraction(double fraction) => _viewStartMilliseconds + (_viewEndMilliseconds - _viewStartMilliseconds) * fraction; + private double XForTime(double milliseconds, Rect plot) => plot.Left + plot.Width * (milliseconds - _viewStartMilliseconds) / Math.Max(1e-12, _viewEndMilliseconds - _viewStartMilliseconds); + private double ToMilliseconds(uint rawTimestamp) => ComtradeTimeMath.ToMilliseconds(rawTimestamp, _timeMultiplier); + private static double TrackHeight(ComtradeDisturbanceTrack track) => track.IsDigital ? DigitalTrackHeight : AnalogTrackHeight; + + private (double Start, double End) GetTimeBounds(IReadOnlyList tracks) + { + var start = double.PositiveInfinity; + var end = double.NegativeInfinity; + foreach (var track in tracks) + { + if (track.Timestamps.Length == 0) continue; + start = Math.Min(start, ToMilliseconds(track.Timestamps[0])); + end = Math.Max(end, ToMilliseconds(track.Timestamps[^1])); + } + if (!double.IsFinite(start) || !double.IsFinite(end) || end <= start) return (0, 1); + return (start, end); + } + + private void RaiseNavigationChanged() + { + var trigger = _triggerMilliseconds ?? 0.0; + var parts = new List + { + $"View {FormatRelative(_viewStartMilliseconds - trigger)} … {FormatRelative(_viewEndMilliseconds - trigger)}" + }; + parts.Add(_cursor1Milliseconds is { } c1 ? $"C1 {FormatRelative(c1 - trigger)}" : "C1 —"); + parts.Add(_cursor2Milliseconds is { } c2 ? $"C2 {FormatRelative(c2 - trigger)}" : "C2 —"); + if (_cursor1Milliseconds is { } first && _cursor2Milliseconds is { } second) + parts.Add($"Δt {Math.Abs(second - first):G6} ms"); + NavigationChanged?.Invoke(this, new ComtradeDisturbanceNavigationChangedEventArgs(string.Join(" | ", parts))); + } + + private static string FormatRelative(double value) => Math.Abs(value) < 0.0005 ? "0 ms" : $"{value:+0.###;-0.###} ms"; + + private static SolidColorBrush FrozenBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + + private static Pen FrozenPen(Color color, double thickness) + { + var pen = new Pen(FrozenBrush(color), thickness); + pen.Freeze(); + return pen; + } + + private static Pen FrozenDashedPen(Color color, double thickness) + { + var pen = new Pen(FrozenBrush(color), thickness) { DashStyle = DashStyles.Dash }; + pen.Freeze(); + return pen; + } + + private static void DrawText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi, double maxWidth = double.PositiveInfinity) + { + var formatted = new FormattedText(text ?? string.Empty, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, size, FrozenBrush(color), dpi) + { + MaxTextWidth = double.IsFinite(maxWidth) ? Math.Max(1, maxWidth) : 10000, + Trimming = TextTrimming.CharacterEllipsis + }; + dc.DrawText(formatted, point); + } +} From 0792e8e09360d88298df8fdf2d78dea86112dfb1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:16:56 +0700 Subject: [PATCH 03/88] P1D.3: add adaptive harmonics workstation view --- Controls/ComtradeHarmonicsWorkstationView.cs | 262 +++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 Controls/ComtradeHarmonicsWorkstationView.cs diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs new file mode 100644 index 000000000..87b3edb5c --- /dev/null +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -0,0 +1,262 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester.Controls; + +/// +/// P1D.3 harmonics workstation: native ArdIrec remains the calculation authority; this control +/// only presents the returned spectrum with adaptive engineering density. +/// +public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement +{ + private const double HeaderHeight = 62.0; + private const double SummaryTop = 62.0; + private const double SummaryHeight = 58.0; + private const double DetailHeight = 64.0; + + private ComtradeHarmonicDisplaySpectrum? _spectrum; + private string _title = "Harmonics"; + private string _subtitle = "Select an analog signal"; + private int _selectedOrder = 1; + private Rect _barsRect; + + public ComtradeHarmonicsWorkstationView() + { + Cursor = Cursors.Arrow; + ToolTip = "Click a harmonic bar to inspect RMS magnitude, percentage of fundamental and phase angle."; + } + + internal void ShowSpectrum(string title, string subtitle, ComtradeHarmonicDisplaySpectrum spectrum) + { + _title = title; + _subtitle = subtitle; + _spectrum = spectrum; + _selectedOrder = spectrum.DominantOrder > 1 && spectrum.Bins.Any(bin => bin.Order == spectrum.DominantOrder) + ? spectrum.DominantOrder + : spectrum.Bins.Count > 0 ? spectrum.Bins[0].Order : 1; + InvalidateVisual(); + } + + internal void ShowMessage(string title, string message) + { + _title = title; + _subtitle = message; + _spectrum = null; + _barsRect = Rect.Empty; + InvalidateVisual(); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + var bounds = new Rect(0, 0, Math.Max(0, ActualWidth), Math.Max(0, ActualHeight)); + dc.DrawRectangle(Brushes.White, null, bounds); + if (bounds.Width < 320 || bounds.Height < 240) return; + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var body = new Typeface("Segoe UI"); + var semibold = new Typeface("Segoe UI Semibold"); + DrawHeader(dc, bounds, dpi, body, semibold); + + if (_spectrum is null || _spectrum.Bins.Count == 0) + { + DrawText(dc, "No valid harmonic spectrum at the analysis reference.", 12, body, + Color.FromRgb(126, 139, 156), new Point(22, 86), dpi); + return; + } + + DrawSummaryCards(dc, bounds, dpi, body, semibold); + var chartTop = SummaryTop + SummaryHeight + 20; + _barsRect = new Rect(64, chartTop, Math.Max(120, bounds.Width - 92), + Math.Max(90, bounds.Height - chartTop - DetailHeight - 30)); + var axisMaximum = HarmonicAxisMaximum(_spectrum.Bins); + DrawGrid(dc, _barsRect, axisMaximum, dpi, body, semibold); + DrawBars(dc, _barsRect, axisMaximum, dpi, body, semibold); + DrawSelectedDetail(dc, new Rect(18, bounds.Bottom - DetailHeight, bounds.Width - 36, DetailHeight - 8), dpi, body, semibold); + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + if (_spectrum is null || _spectrum.Bins.Count == 0 || !_barsRect.Contains(e.GetPosition(this))) return; + var point = e.GetPosition(this); + var fraction = Math.Clamp((point.X - _barsRect.Left) / _barsRect.Width, 0.0, 0.999999); + var index = Math.Clamp((int)(fraction * _spectrum.Bins.Count), 0, _spectrum.Bins.Count - 1); + _selectedOrder = _spectrum.Bins[index].Order; + InvalidateVisual(); + e.Handled = true; + } + + private void DrawHeader(DrawingContext dc, Rect bounds, double dpi, Typeface body, Typeface semibold) + { + DrawText(dc, _title, 15.5, semibold, Color.FromRgb(29, 49, 73), new Point(18, 13), dpi, + Math.Max(160, bounds.Width - 36)); + DrawText(dc, _subtitle, 10.4, body, Color.FromRgb(103, 120, 141), new Point(18, 39), dpi, + Math.Max(160, bounds.Width - 36)); + dc.DrawLine(FrozenPen(Color.FromRgb(235, 239, 244), 1), new Point(18, HeaderHeight - 1), new Point(bounds.Right - 18, HeaderHeight - 1)); + } + + private void DrawSummaryCards(DrawingContext dc, Rect bounds, double dpi, Typeface body, Typeface semibold) + { + if (_spectrum is null) return; + var units = string.IsNullOrWhiteSpace(_spectrum.Units) ? string.Empty : " " + _spectrum.Units; + var dominant = _spectrum.DominantOrder > 1 + ? $"H{_spectrum.DominantOrder} {_spectrum.DominantPercent:G4}%" + : "—"; + var nyquist = _spectrum.MaximumResolvableOrder > 0 ? $"H{_spectrum.MaximumResolvableOrder}" : "—"; + var items = new[] + { + ("FUNDAMENTAL RMS", $"{_spectrum.FundamentalRms:G6}{units}"), + ("THD", $"{_spectrum.ThdPercent:G4}%"), + ("DOMINANT", dominant), + ("NYQUIST LIMIT", nyquist) + }; + + var left = 18.0; + var gap = 8.0; + var available = bounds.Width - 36 - gap * (items.Length - 1); + var width = Math.Max(120, available / items.Length); + for (var i = 0; i < items.Length; i++) + { + var rect = new Rect(left + i * (width + gap), SummaryTop + 2, width, SummaryHeight - 8); + dc.DrawRoundedRectangle(FrozenBrush(Color.FromRgb(248, 250, 253)), FrozenPen(Color.FromRgb(226, 232, 240), 1), rect, 6, 6); + DrawText(dc, items[i].Item1, 8.5, body, Color.FromRgb(129, 143, 159), new Point(rect.Left + 10, rect.Top + 7), dpi, rect.Width - 20); + DrawText(dc, items[i].Item2, 11.2, semibold, Color.FromRgb(45, 65, 89), new Point(rect.Left + 10, rect.Top + 25), dpi, rect.Width - 20); + } + } + + private static double HarmonicAxisMaximum(IReadOnlyList bins) + { + var measured = bins.Count == 0 ? 100.0 : bins.Max(bin => + double.IsFinite(bin.PercentOfFundamental) ? Math.Max(0.0, bin.PercentOfFundamental) : 0.0); + measured = Math.Max(100.0, measured); + var roughStep = measured / 4.0; + var exponent = Math.Pow(10.0, Math.Floor(Math.Log10(Math.Max(roughStep, 1e-9)))); + var normalized = roughStep / exponent; + var step = normalized <= 1.0 ? 1.0 : normalized <= 2.0 ? 2.0 : normalized <= 5.0 ? 5.0 : 10.0; + step *= exponent; + return Math.Ceiling(measured / step) * step; + } + + private static void DrawGrid(DrawingContext dc, Rect plot, double axisMaximum, double dpi, Typeface body, Typeface semibold) + { + var gridPen = FrozenPen(Color.FromRgb(233, 237, 242), 1); + var borderPen = FrozenPen(Color.FromRgb(193, 203, 215), 1); + for (var i = 0; i <= 4; i++) + { + var y = plot.Bottom - plot.Height * i / 4.0; + dc.DrawLine(gridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + var value = axisMaximum * i / 4.0; + DrawRightAlignedText(dc, $"{value:G4}%", 8.5, body, Color.FromRgb(128, 141, 157), new Point(plot.Left - 8, y - 6), dpi); + } + + if (axisMaximum >= 5.0) + { + var y5 = plot.Bottom - plot.Height * 5.0 / axisMaximum; + dc.DrawLine(FrozenDashedPen(Color.FromRgb(203, 168, 91), 1), new Point(plot.Left, y5), new Point(plot.Right, y5)); + DrawText(dc, "5%", 8.0, semibold, Color.FromRgb(160, 126, 58), new Point(plot.Right - 25, y5 - 13), dpi); + } + dc.DrawRectangle(null, borderPen, plot); + } + + private void DrawBars(DrawingContext dc, Rect plot, double axisMaximum, double dpi, Typeface body, Typeface semibold) + { + if (_spectrum is null) return; + var bins = _spectrum.Bins; + var slot = plot.Width / Math.Max(1, bins.Count); + var barWidth = Math.Clamp(slot * 0.62, 3.0, 24.0); + var labelStride = ComtradeInteractionPerformanceMath.LabelStride(bins.Count, plot.Width, 42.0); + + for (var i = 0; i < bins.Count; i++) + { + var bin = bins[i]; + var percent = double.IsFinite(bin.PercentOfFundamental) ? Math.Max(0, bin.PercentOfFundamental) : 0; + var height = Math.Clamp(percent / axisMaximum, 0, 1) * plot.Height; + var x = plot.Left + slot * (i + 0.5) - barWidth * 0.5; + var selected = bin.Order == _selectedOrder; + var dominant = _spectrum.DominantOrder > 1 && bin.Order == _spectrum.DominantOrder; + var color = bin.Order == 1 + ? Color.FromRgb(42, 120, 223) + : selected ? Color.FromRgb(217, 121, 41) + : dominant ? Color.FromRgb(76, 139, 197) + : Color.FromRgb(134, 172, 210); + dc.DrawRoundedRectangle(FrozenBrush(color), null, + new Rect(x, plot.Bottom - Math.Max(1.0, height), barWidth, Math.Max(1.0, height)), 2, 2); + + if (selected) + dc.DrawRectangle(null, FrozenPen(Color.FromRgb(194, 97, 24), 1.2), new Rect(x - 2, plot.Top + 2, barWidth + 4, plot.Height - 4)); + + var showLabel = bin.Order == 1 || selected || dominant || i % labelStride == 0; + if (!showLabel) continue; + var label = $"H{bin.Order}"; + DrawCenteredText(dc, label, selected ? 8.8 : 8.4, selected ? semibold : body, + selected ? Color.FromRgb(181, 91, 28) : Color.FromRgb(103, 116, 132), + new Point(x + barWidth * 0.5, plot.Bottom + 5), dpi); + } + } + + private void DrawSelectedDetail(DrawingContext dc, Rect rect, double dpi, Typeface body, Typeface semibold) + { + if (_spectrum is null) return; + var selected = _spectrum.Bins.FirstOrDefault(bin => bin.Order == _selectedOrder) ?? _spectrum.Bins[0]; + var unit = string.IsNullOrWhiteSpace(_spectrum.Units) ? string.Empty : " " + _spectrum.Units; + dc.DrawRoundedRectangle(FrozenBrush(Color.FromRgb(248, 250, 253)), FrozenPen(Color.FromRgb(222, 230, 239), 1), rect, 6, 6); + DrawText(dc, $"H{selected.Order}", 12, semibold, Color.FromRgb(44, 64, 87), new Point(rect.Left + 12, rect.Top + 8), dpi); + DrawText(dc, $"{selected.MagnitudeRms:G6}{unit} RMS", 10.2, semibold, Color.FromRgb(70, 91, 116), new Point(rect.Left + 62, rect.Top + 8), dpi); + DrawText(dc, $"{selected.PercentOfFundamental:G5}% of fundamental", 9.5, body, Color.FromRgb(102, 119, 139), new Point(rect.Left + 210, rect.Top + 9), dpi); + DrawText(dc, $"Phase ∠ {selected.AngleDegrees:+0.##;-0.##;0}°", 9.4, body, Color.FromRgb(111, 126, 145), new Point(rect.Left + 62, rect.Top + 29), dpi); + if (_spectrum.EstimatedSampleRateHz > 0) + DrawText(dc, $"Sample rate {_spectrum.EstimatedSampleRateHz:G6} Hz", 9.1, body, Color.FromRgb(130, 142, 157), new Point(rect.Right - 180, rect.Top + 29), dpi, 168); + } + + private static SolidColorBrush FrozenBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + + private static Pen FrozenPen(Color color, double thickness) + { + var pen = new Pen(FrozenBrush(color), thickness); + pen.Freeze(); + return pen; + } + + private static Pen FrozenDashedPen(Color color, double thickness) + { + var pen = new Pen(FrozenBrush(color), thickness) { DashStyle = DashStyles.Dash }; + pen.Freeze(); + return pen; + } + + private static FormattedText MakeText(string text, double size, Typeface typeface, Color color, double dpi, double maxWidth = 10000) + { + return new FormattedText(text ?? string.Empty, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, size, FrozenBrush(color), dpi) + { + MaxTextWidth = Math.Max(1, maxWidth), + Trimming = TextTrimming.CharacterEllipsis + }; + } + + private static void DrawText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi, double maxWidth = 10000) + => dc.DrawText(MakeText(text, size, typeface, color, dpi, maxWidth), point); + + private static void DrawCenteredText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi) + { + var formatted = MakeText(text, size, typeface, color, dpi); + formatted.TextAlignment = TextAlignment.Center; + dc.DrawText(formatted, point); + } + + private static void DrawRightAlignedText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi) + { + var formatted = MakeText(text, size, typeface, color, dpi); + formatted.TextAlignment = TextAlignment.Right; + dc.DrawText(formatted, point); + } +} From c2e0006b9e55418719123f9f19dfeaee3cc7c223 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:17:43 +0700 Subject: [PATCH 04/88] P1D.3: switch workspace to optimized renderers --- ComtradeWorkspaceWindow.xaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 467690abc..c42ffe58b 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -210,7 +210,7 @@ - + @@ -232,7 +232,7 @@ - + @@ -251,8 +251,8 @@ BorderBrush="#C5D8EE" BorderThickness="1" Cursor="Hand" ToolTip="Open the complete Qt analysis workspace for field comparison." Click="FullAnalysis_Click"/> - + - + \ No newline at end of file From 41902b7c36ce2a395af0bbb2045de7ca705267c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:18:16 +0700 Subject: [PATCH 05/88] P1D.3: cover snap and adaptive density math --- ...ComtradeInteractionPerformanceMathTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/ARSAS.Tests/ComtradeInteractionPerformanceMathTests.cs diff --git a/tests/ARSAS.Tests/ComtradeInteractionPerformanceMathTests.cs b/tests/ARSAS.Tests/ComtradeInteractionPerformanceMathTests.cs new file mode 100644 index 000000000..6838bc6ef --- /dev/null +++ b/tests/ARSAS.Tests/ComtradeInteractionPerformanceMathTests.cs @@ -0,0 +1,44 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ComtradeInteractionPerformanceMathTests +{ + [Fact] + public void NearestSortedIndex_FindsClosestEdgeWithoutLinearScan() + { + var values = new[] { -20.0, -1.25, 0.0, 4.5, 40.0 }; + + Assert.Equal(1, ComtradeInteractionPerformanceMath.NearestSortedIndex(values, -1.0)); + Assert.Equal(3, ComtradeInteractionPerformanceMath.NearestSortedIndex(values, 5.0)); + Assert.Equal(0, ComtradeInteractionPerformanceMath.NearestSortedIndex(values, -100.0)); + Assert.Equal(4, ComtradeInteractionPerformanceMath.NearestSortedIndex(values, 100.0)); + } + + [Fact] + public void TrySnapSorted_RespectsCurrentPixelDerivedTolerance() + { + var values = new[] { 0.0, 10.0, 20.0 }; + + Assert.True(ComtradeInteractionPerformanceMath.TrySnapSorted(values, 10.7, 1.0, out var snapped)); + Assert.Equal(10.0, snapped, 9); + Assert.False(ComtradeInteractionPerformanceMath.TrySnapSorted(values, 12.0, 1.0, out _)); + } + + [Theory] + [InlineData(25, 1000, 42, 2)] + [InlineData(10, 1000, 42, 1)] + [InlineData(40, 420, 42, 4)] + public void LabelStride_AdaptsToAvailablePixels(int itemCount, double width, double minimumSpacing, int expected) + { + Assert.Equal(expected, ComtradeInteractionPerformanceMath.LabelStride(itemCount, width, minimumSpacing)); + } + + [Fact] + public void TransitionGlyphSpacing_SuppressesUnreadableTextNoise() + { + Assert.True(ComtradeInteractionPerformanceMath.ShouldDrawTransitionGlyph(100, double.NegativeInfinity, 28)); + Assert.False(ComtradeInteractionPerformanceMath.ShouldDrawTransitionGlyph(115, 100, 28)); + Assert.True(ComtradeInteractionPerformanceMath.ShouldDrawTransitionGlyph(129, 100, 28)); + } +} From 99d5c33255232131f9dcad84961968643ded8712 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:18:42 +0700 Subject: [PATCH 06/88] P1D.3: document performance and harmonics contract --- docs/P1D3_HARMONICS_PERFORMANCE.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/P1D3_HARMONICS_PERFORMANCE.md diff --git a/docs/P1D3_HARMONICS_PERFORMANCE.md b/docs/P1D3_HARMONICS_PERFORMANCE.md new file mode 100644 index 000000000..98330c609 --- /dev/null +++ b/docs/P1D3_HARMONICS_PERFORMANCE.md @@ -0,0 +1,37 @@ +# P1D.3 — responsive disturbance + harmonics workstation + +P1D.3 starts the Harmonics workstation-parity slice and fixes the field-observed interaction bottleneck in Time Signals. + +## Time Signals performance contract + +- C1/C2 movement must not rebuild analog/digital waveform geometry. +- Heavy track rendering is cached; cursor lines are a lightweight overlay. +- Drag-pan uses a translated preview and commits one local viewport update on mouse-up. +- Visible digital edge snapping uses one precomputed sorted time index and binary search instead of rescanning every digital transition on each pointer move. +- Host navigation/cursor notifications are capped at about 30 Hz during drag; final placement is always emitted. +- Native exact edge snap still runs on final cursor placement through the existing ArdIrec bridge. + +## Text-density contract + +- Digital lanes are title-only inside the plot; normal-state/circuit metadata remains in the Signals browser and event table. +- Repeated analog min/max micro-labels are removed from each track. +- Digital transition lines are collapsed to unique screen pixels. +- Up/down transition glyphs are displayed only when the current view has enough horizontal space to read them. +- Time-axis tick count adapts to available plot width. + +## Harmonics P1D.3 start + +- Native ArdIrec harmonic calculation remains authoritative; no managed DSP is added. +- Harmonics keeps selected analog channel + global C1 as the analysis-reference contract in this first P1D.3 slice. +- The WPF presentation is upgraded to a workstation layout with Fundamental RMS, THD, dominant harmonic, Nyquist limit, adaptive spectrum labels, a 5% engineering guide, and a compact selected-harmonic detail strip. +- Harmonic labels adapt to available pixels so H1 / selected / dominant information remains readable without a wall of text. + +## Field acceptance + +Use the same protection record that exposed the issue and verify: + +1. continuous C1/C2 drag feels immediate and no longer redraws all tracks; +2. left/right pan previews immediately and commits/reloads only when the gesture completes; +3. dense protection transitions remain visually legible; +4. sparse transitions still show useful direction arrows; +5. Harmonics remains synchronized to C1 and renders without overlapping order labels. From 8e9f014a98d6a02ad880ed715093b20cca7eb4d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:18:07 +0700 Subject: [PATCH 07/88] P1D.3: normalize COMTRADE data timestamps to record start --- Services/ComtradeTimeMath.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Services/ComtradeTimeMath.cs b/Services/ComtradeTimeMath.cs index 816b92952..0e44f340c 100644 --- a/Services/ComtradeTimeMath.cs +++ b/Services/ComtradeTimeMath.cs @@ -26,6 +26,18 @@ internal static double ToMilliseconds(uint rawTimestamp, double timeMultiplier) return rawTimestamp * multiplier / 1000.0; } + /// + /// Converts a COMTRADE DAT timestamp to elapsed milliseconds from the first recorded sample. + /// Some legacy/third-party records start DAT timestamps at one sample period rather than zero; + /// StartTime still describes the first recorded sample. Subtracting the first raw timestamp is + /// therefore required before comparing sample time with the CFG trigger offset. + /// + internal static double ToRecordMilliseconds(uint rawTimestamp, uint firstRawTimestamp, double timeMultiplier) + { + var multiplier = timeMultiplier > 0 && double.IsFinite(timeMultiplier) ? timeMultiplier : 1.0; + return (rawTimestamp - (double)firstRawTimestamp) * multiplier / 1000.0; + } + internal static bool TryGetTriggerOffsetMilliseconds(string startText, string triggerText, out double offsetMilliseconds) { offsetMilliseconds = 0; From af8a9cae6a081ee4e587289ac557e026cc6649cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:18:26 +0700 Subject: [PATCH 08/88] P1D.3: test non-zero DAT timestamp origin normalization --- tests/ARSAS.Tests/ComtradeTimeMathTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ARSAS.Tests/ComtradeTimeMathTests.cs b/tests/ARSAS.Tests/ComtradeTimeMathTests.cs index bf1dff8c9..67f6cd4e1 100644 --- a/tests/ARSAS.Tests/ComtradeTimeMathTests.cs +++ b/tests/ARSAS.Tests/ComtradeTimeMathTests.cs @@ -52,4 +52,14 @@ public void ToMilliseconds_AppliesTimeMultiplier() Assert.Equal(5.0, ComtradeTimeMath.ToMilliseconds(1000, 5.0), 8); Assert.Equal(1.0, ComtradeTimeMath.ToMilliseconds(1000, double.NaN), 8); } + + [Fact] + public void ToRecordMilliseconds_SubtractsNonZeroFirstDatTimestamp() + { + // Legacy records may encode sample 1 at +1 sample period while CFG StartTime still names + // sample 1. Trigger and waveform must therefore share the same elapsed-time origin. + Assert.Equal(0.0, ComtradeTimeMath.ToRecordMilliseconds(1000, 1000, 1.0), 8); + Assert.Equal(124.0, ComtradeTimeMath.ToRecordMilliseconds(125000, 1000, 1.0), 8); + Assert.Equal(620.0, ComtradeTimeMath.ToRecordMilliseconds(125000, 1000, 5.0), 8); + } } From 458b2bfafa3d80c558f2babe7632433124c10391 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:18:47 +0700 Subject: [PATCH 09/88] P1D.3: add trigger-anchored shared timeline math --- Services/ComtradeInvestigationTimelineMath.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 Services/ComtradeInvestigationTimelineMath.cs diff --git a/Services/ComtradeInvestigationTimelineMath.cs b/Services/ComtradeInvestigationTimelineMath.cs new file mode 100644 index 000000000..f99aa32cf --- /dev/null +++ b/Services/ComtradeInvestigationTimelineMath.cs @@ -0,0 +1,67 @@ +namespace ArIED61850Tester.Services; + +internal readonly record struct ComtradeTimelineTick(double AbsoluteMilliseconds, double RelativeMilliseconds); + +internal static class ComtradeInvestigationTimelineMath +{ + internal static IReadOnlyList BuildTriggerAnchoredTicks( + double viewStartMilliseconds, + double viewEndMilliseconds, + double? triggerMilliseconds, + int approximateTickCount) + { + if (!double.IsFinite(viewStartMilliseconds) || !double.IsFinite(viewEndMilliseconds) || + viewEndMilliseconds <= viewStartMilliseconds) + return Array.Empty(); + + var span = viewEndMilliseconds - viewStartMilliseconds; + approximateTickCount = Math.Clamp(approximateTickCount, 2, 20); + var step = NiceStep(span / approximateTickCount); + if (!double.IsFinite(step) || step <= 0) + return Array.Empty(); + + var origin = triggerMilliseconds is { } trigger && double.IsFinite(trigger) ? trigger : 0.0; + var relativeStart = viewStartMilliseconds - origin; + var relativeEnd = viewEndMilliseconds - origin; + var firstIndex = (long)Math.Ceiling(relativeStart / step - 1e-10); + var lastIndex = (long)Math.Floor(relativeEnd / step + 1e-10); + if (lastIndex < firstIndex) + return Array.Empty(); + + var count = (int)Math.Min(64, lastIndex - firstIndex + 1); + var ticks = new List(count); + for (var offset = 0; offset < count; offset++) + { + var index = firstIndex + offset; + var relative = index * step; + if (Math.Abs(relative) < step * 1e-10) + relative = 0; + ticks.Add(new ComtradeTimelineTick(origin + relative, relative)); + } + return ticks; + } + + internal static double NiceStep(double rawStep) + { + if (!double.IsFinite(rawStep) || rawStep <= 0) + return 1.0; + var exponent = Math.Floor(Math.Log10(rawStep)); + var scale = Math.Pow(10.0, exponent); + var normalized = rawStep / scale; + var nice = normalized <= 1.0 ? 1.0 + : normalized <= 2.0 ? 2.0 + : normalized <= 2.5 ? 2.5 + : normalized <= 5.0 ? 5.0 + : 10.0; + return nice * scale; + } + + internal static double ClampToRecord(double milliseconds, double fullStartMilliseconds, double fullEndMilliseconds) + { + if (!double.IsFinite(milliseconds)) + return fullStartMilliseconds; + if (!double.IsFinite(fullStartMilliseconds) || !double.IsFinite(fullEndMilliseconds) || fullEndMilliseconds < fullStartMilliseconds) + return milliseconds; + return Math.Clamp(milliseconds, fullStartMilliseconds, fullEndMilliseconds); + } +} From 5b3d9f4dea81631a8bb44cfa5b57458bf2a0e3d0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:19:02 +0700 Subject: [PATCH 10/88] P1D.3: cover trigger-anchored shell timeline ticks --- .../ComtradeInvestigationTimelineMathTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/ARSAS.Tests/ComtradeInvestigationTimelineMathTests.cs diff --git a/tests/ARSAS.Tests/ComtradeInvestigationTimelineMathTests.cs b/tests/ARSAS.Tests/ComtradeInvestigationTimelineMathTests.cs new file mode 100644 index 000000000..8c2672b81 --- /dev/null +++ b/tests/ARSAS.Tests/ComtradeInvestigationTimelineMathTests.cs @@ -0,0 +1,44 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ComtradeInvestigationTimelineMathTests +{ + [Fact] + public void TriggerAnchoredTicks_AlwaysContainExactZeroWhenTriggerVisible() + { + var ticks = ComtradeInvestigationTimelineMath.BuildTriggerAnchoredTicks(52.0, 212.0, 124.0, 8); + + Assert.Contains(ticks, tick => tick.RelativeMilliseconds == 0.0 && tick.AbsoluteMilliseconds == 124.0); + } + + [Fact] + public void TriggerAnchoredTicks_AreSymmetricEngineeringStepsAroundTrigger() + { + var ticks = ComtradeInvestigationTimelineMath.BuildTriggerAnchoredTicks(40.0, 200.0, 120.0, 8); + var relative = ticks.Select(tick => tick.RelativeMilliseconds).ToArray(); + + Assert.Contains(-80.0, relative); + Assert.Contains(-60.0, relative); + Assert.Contains(0.0, relative); + Assert.Contains(60.0, relative); + Assert.Contains(80.0, relative); + } + + [Fact] + public void NiceStep_UsesEngineeringFriendlySequence() + { + Assert.Equal(0.5, ComtradeInvestigationTimelineMath.NiceStep(0.41), 8); + Assert.Equal(2.5, ComtradeInvestigationTimelineMath.NiceStep(2.2), 8); + Assert.Equal(20.0, ComtradeInvestigationTimelineMath.NiceStep(17.0), 8); + Assert.Equal(50.0, ComtradeInvestigationTimelineMath.NiceStep(41.0), 8); + } + + [Fact] + public void ClampToRecord_ClampsCursorWithoutChangingFiniteInteriorValue() + { + Assert.Equal(10.0, ComtradeInvestigationTimelineMath.ClampToRecord(10.0, 0.0, 100.0), 8); + Assert.Equal(0.0, ComtradeInvestigationTimelineMath.ClampToRecord(-2.0, 0.0, 100.0), 8); + Assert.Equal(100.0, ComtradeInvestigationTimelineMath.ClampToRecord(120.0, 0.0, 100.0), 8); + } +} From addba7b21c9db27d9b84f1986c252c9a4b7006eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:20:07 +0700 Subject: [PATCH 11/88] P1D.3: add persistent SIGRA-style investigation timeline shell --- Controls/ComtradeInvestigationTimelineView.cs | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 Controls/ComtradeInvestigationTimelineView.cs diff --git a/Controls/ComtradeInvestigationTimelineView.cs b/Controls/ComtradeInvestigationTimelineView.cs new file mode 100644 index 000000000..40779bca7 --- /dev/null +++ b/Controls/ComtradeInvestigationTimelineView.cs @@ -0,0 +1,390 @@ +using System.Diagnostics; +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester.Controls; + +internal enum ComtradeInvestigationTimelineMode +{ + DualCursor, + HarmonicCursor +} + +internal enum ComtradeInvestigationTimelineCursor +{ + Cursor1, + Cursor2, + Harmonic +} + +internal sealed class ComtradeInvestigationTimelineCursorChangedEventArgs : EventArgs +{ + internal ComtradeInvestigationTimelineCursorChangedEventArgs( + ComtradeInvestigationTimelineCursor cursor, + double absoluteMilliseconds, + double snapToleranceMilliseconds, + bool isFinal) + { + Cursor = cursor; + AbsoluteMilliseconds = absoluteMilliseconds; + SnapToleranceMilliseconds = snapToleranceMilliseconds; + IsFinal = isFinal; + } + + internal ComtradeInvestigationTimelineCursor Cursor { get; } + internal double AbsoluteMilliseconds { get; } + internal double SnapToleranceMilliseconds { get; } + internal bool IsFinal { get; } +} + +/// +/// Persistent investigation ruler shared by Time Signals, Phasor and Harmonics. It intentionally +/// owns only lightweight cursor interaction; waveform rendering and native analysis remain in their +/// dedicated views. This mirrors the workstation model where one timeline context drives all views. +/// +public sealed class ComtradeInvestigationTimelineView : FrameworkElement +{ + private const double LeftInset = 150.0; + private const double RightInset = 18.0; + private const double CursorHitRadius = 10.0; + private static readonly long InteractiveNotifyTicks = Math.Max(1, Stopwatch.Frequency / 30); + + private ComtradeInvestigationTimelineMode _mode = ComtradeInvestigationTimelineMode.DualCursor; + private ComtradeInvestigationTimelineCursor _dragCursor; + private bool _dragging; + private double _fullStartMilliseconds; + private double _fullEndMilliseconds = 1.0; + private double _viewStartMilliseconds; + private double _viewEndMilliseconds = 1.0; + private double? _triggerMilliseconds; + private double? _cursor1Milliseconds; + private double? _cursor2Milliseconds; + private double? _harmonicCursorMilliseconds; + private Rect _rulerRect; + private long _lastInteractiveNotify; + + internal event EventHandler? CursorChanged; + + internal ComtradeInvestigationTimelineMode Mode => _mode; + internal double? Cursor1Milliseconds => _cursor1Milliseconds; + internal double? Cursor2Milliseconds => _cursor2Milliseconds; + internal double? HarmonicCursorMilliseconds => _harmonicCursorMilliseconds; + + public ComtradeInvestigationTimelineView() + { + Height = 66; + MinHeight = 66; + Focusable = true; + Cursor = Cursors.Arrow; + ToolTipService.SetInitialShowDelay(this, 1200); + ToolTip = "Drag C1/C2 to investigate. Harmonics uses one H cursor."; + } + + internal void SetMode(ComtradeInvestigationTimelineMode mode) + { + if (_mode == mode) return; + _mode = mode; + InvalidateVisual(); + } + + internal void SetContext( + double fullStartMilliseconds, + double fullEndMilliseconds, + double viewStartMilliseconds, + double viewEndMilliseconds, + double? triggerMilliseconds, + double? cursor1Milliseconds, + double? cursor2Milliseconds, + double? harmonicCursorMilliseconds) + { + _fullStartMilliseconds = double.IsFinite(fullStartMilliseconds) ? fullStartMilliseconds : 0.0; + _fullEndMilliseconds = double.IsFinite(fullEndMilliseconds) && fullEndMilliseconds > _fullStartMilliseconds + ? fullEndMilliseconds + : _fullStartMilliseconds + 1.0; + _viewStartMilliseconds = double.IsFinite(viewStartMilliseconds) ? viewStartMilliseconds : _fullStartMilliseconds; + _viewEndMilliseconds = double.IsFinite(viewEndMilliseconds) && viewEndMilliseconds > _viewStartMilliseconds + ? viewEndMilliseconds + : _fullEndMilliseconds; + _triggerMilliseconds = triggerMilliseconds is { } trigger && double.IsFinite(trigger) ? trigger : null; + _cursor1Milliseconds = cursor1Milliseconds is { } c1 && double.IsFinite(c1) ? c1 : null; + _cursor2Milliseconds = cursor2Milliseconds is { } c2 && double.IsFinite(c2) ? c2 : null; + _harmonicCursorMilliseconds = harmonicCursorMilliseconds is { } harmonic && double.IsFinite(harmonic) ? harmonic : null; + InvalidateVisual(); + } + + internal void SetCursorFromHost(ComtradeInvestigationTimelineCursor cursor, double milliseconds) + { + if (!double.IsFinite(milliseconds)) return; + milliseconds = ComtradeInvestigationTimelineMath.ClampToRecord(milliseconds, _fullStartMilliseconds, _fullEndMilliseconds); + switch (cursor) + { + case ComtradeInvestigationTimelineCursor.Cursor1: + _cursor1Milliseconds = milliseconds; + break; + case ComtradeInvestigationTimelineCursor.Cursor2: + _cursor2Milliseconds = milliseconds; + break; + case ComtradeInvestigationTimelineCursor.Harmonic: + _harmonicCursorMilliseconds = milliseconds; + break; + } + InvalidateVisual(); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + var width = Math.Max(0.0, ActualWidth); + var height = Math.Max(0.0, ActualHeight); + if (width < 260 || height < 48) return; + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var body = new Typeface("Segoe UI"); + var semibold = new Typeface("Segoe UI Semibold"); + dc.DrawRectangle(FrozenBrush(Color.FromRgb(250, 252, 255)), null, new Rect(0, 0, width, height)); + dc.DrawLine(FrozenPen(Color.FromRgb(226, 233, 242), 1), new Point(0, height - 1), new Point(width, height - 1)); + + var left = Math.Min(LeftInset, Math.Max(18, width * 0.22)); + var right = Math.Max(left + 80, width - RightInset); + _rulerRect = new Rect(left, 28, Math.Max(80, right - left), 30); + + DrawReadout(dc, dpi, body, semibold); + DrawRuler(dc, dpi, body, semibold); + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + if (_rulerRect.Width <= 0 || !_rulerRect.Contains(e.GetPosition(this))) return; + + Focus(); + var point = e.GetPosition(this); + if (_mode == ComtradeInvestigationTimelineMode.HarmonicCursor) + { + _dragCursor = ComtradeInvestigationTimelineCursor.Harmonic; + } + else if (e.ChangedButton == MouseButton.Right) + { + _dragCursor = ComtradeInvestigationTimelineCursor.Cursor2; + } + else if (IsNear(point.X, _cursor2Milliseconds)) + { + _dragCursor = ComtradeInvestigationTimelineCursor.Cursor2; + } + else + { + _dragCursor = ComtradeInvestigationTimelineCursor.Cursor1; + } + + if (e.ChangedButton is not (MouseButton.Left or MouseButton.Right)) return; + _dragging = true; + CaptureMouse(); + Cursor = Cursors.SizeWE; + PlaceCursor(_dragCursor, TimeAtX(point.X), isFinal: false, forceNotify: true); + e.Handled = true; + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + var point = e.GetPosition(this); + if (!_dragging || !IsMouseCaptured) + { + Cursor = HoverCursor(point.X); + return; + } + + PlaceCursor(_dragCursor, TimeAtX(point.X), isFinal: false, forceNotify: false); + e.Handled = true; + } + + protected override void OnMouseUp(MouseButtonEventArgs e) + { + base.OnMouseUp(e); + if (!_dragging) return; + var point = e.GetPosition(this); + _dragging = false; + if (IsMouseCaptured) ReleaseMouseCapture(); + Cursor = Cursors.Arrow; + PlaceCursor(_dragCursor, TimeAtX(point.X), isFinal: true, forceNotify: true); + e.Handled = true; + } + + protected override void OnLostMouseCapture(MouseEventArgs e) + { + base.OnLostMouseCapture(e); + _dragging = false; + Cursor = Cursors.Arrow; + } + + private void PlaceCursor( + ComtradeInvestigationTimelineCursor cursor, + double milliseconds, + bool isFinal, + bool forceNotify) + { + milliseconds = ComtradeInvestigationTimelineMath.ClampToRecord(milliseconds, _fullStartMilliseconds, _fullEndMilliseconds); + SetCursorFromHost(cursor, milliseconds); + + var now = Stopwatch.GetTimestamp(); + if (!forceNotify && !isFinal && now - _lastInteractiveNotify < InteractiveNotifyTicks) + return; + _lastInteractiveNotify = now; + var tolerance = (_viewEndMilliseconds - _viewStartMilliseconds) * 10.0 / Math.Max(1.0, _rulerRect.Width); + CursorChanged?.Invoke(this, new ComtradeInvestigationTimelineCursorChangedEventArgs( + cursor, + milliseconds, + Math.Max(0.0, tolerance), + isFinal)); + } + + private void DrawReadout(DrawingContext dc, double dpi, Typeface body, Typeface semibold) + { + var trigger = _triggerMilliseconds ?? 0.0; + if (_mode == ComtradeInvestigationTimelineMode.HarmonicCursor) + { + DrawText(dc, "HARMONIC CURSOR", 8.6, semibold, Color.FromRgb(86, 102, 122), new Point(10, 6), dpi); + DrawText(dc, + _harmonicCursorMilliseconds is { } harmonic ? $"H {FormatRelative(harmonic - trigger)}" : "H —", + 9.6, semibold, Color.FromRgb(220, 127, 35), new Point(112, 4), dpi); + DrawText(dc, "single analysis reference", 8.2, body, Color.FromRgb(132, 144, 159), new Point(220, 6), dpi); + return; + } + + DrawText(dc, "INVESTIGATION CURSORS", 8.6, semibold, Color.FromRgb(86, 102, 122), new Point(10, 6), dpi); + DrawText(dc, + _cursor1Milliseconds is { } c1 ? $"C1 {FormatRelative(c1 - trigger)}" : "C1 —", + 9.2, semibold, Color.FromRgb(205, 128, 24), new Point(132, 4), dpi); + DrawText(dc, + _cursor2Milliseconds is { } c2 ? $"C2 {FormatRelative(c2 - trigger)}" : "C2 —", + 9.2, semibold, Color.FromRgb(26, 145, 184), new Point(232, 4), dpi); + if (_cursor1Milliseconds is { } first && _cursor2Milliseconds is { } second) + DrawText(dc, $"Δt {Math.Abs(second - first):0.###} ms", 9.0, body, + Color.FromRgb(91, 107, 127), new Point(334, 4), dpi); + } + + private void DrawRuler(DrawingContext dc, double dpi, Typeface body, Typeface semibold) + { + var y = _rulerRect.Top + 14; + dc.DrawLine(FrozenPen(Color.FromRgb(174, 186, 201), 1), new Point(_rulerRect.Left, y), new Point(_rulerRect.Right, y)); + + var approximateTicks = Math.Clamp((int)Math.Round(_rulerRect.Width / 125.0), 4, 10); + var ticks = ComtradeInvestigationTimelineMath.BuildTriggerAnchoredTicks( + _viewStartMilliseconds, + _viewEndMilliseconds, + _triggerMilliseconds, + approximateTicks); + foreach (var tick in ticks) + { + var x = XForTime(tick.AbsoluteMilliseconds); + var isTriggerTick = Math.Abs(tick.RelativeMilliseconds) < 1e-9 && _triggerMilliseconds.HasValue; + var pen = isTriggerTick + ? FrozenPen(Color.FromRgb(70, 78, 89), 1.4) + : FrozenPen(Color.FromRgb(202, 211, 222), 1); + dc.DrawLine(pen, new Point(x, y - (isTriggerTick ? 12 : 4)), new Point(x, y + 5)); + var label = isTriggerTick ? "0" : tick.RelativeMilliseconds.ToString("+0.###;-0.###", CultureInfo.CurrentCulture); + DrawText(dc, label, 7.8, body, + isTriggerTick ? Color.FromRgb(58, 65, 74) : Color.FromRgb(117, 130, 146), + new Point(x - 12, y + 7), dpi, 32); + } + + if (_triggerMilliseconds is { } trigger && trigger >= _viewStartMilliseconds && trigger <= _viewEndMilliseconds) + { + var x = XForTime(trigger); + dc.DrawLine(FrozenPen(Color.FromRgb(55, 62, 72), 1.4), new Point(x, _rulerRect.Top), new Point(x, _rulerRect.Bottom - 1)); + DrawText(dc, "TRG", 7.6, semibold, Color.FromRgb(55, 62, 72), new Point(x + 4, _rulerRect.Top), dpi); + } + + if (_mode == ComtradeInvestigationTimelineMode.HarmonicCursor) + { + DrawCursorMarker(dc, _harmonicCursorMilliseconds, "H", Color.FromRgb(226, 132, 38), dpi, semibold); + return; + } + + DrawCursorMarker(dc, _cursor1Milliseconds, "C1", Color.FromRgb(221, 142, 32), dpi, semibold); + DrawCursorMarker(dc, _cursor2Milliseconds, "C2", Color.FromRgb(36, 172, 211), dpi, semibold); + } + + private void DrawCursorMarker(DrawingContext dc, double? milliseconds, string label, Color color, double dpi, Typeface semibold) + { + if (milliseconds is not { } ms || ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) return; + var x = XForTime(ms); + var brush = FrozenBrush(color); + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + context.BeginFigure(new Point(x, _rulerRect.Top + 2), true, true); + context.LineTo(new Point(x - 5, _rulerRect.Top - 4), true, false); + context.LineTo(new Point(x, _rulerRect.Top - 10), true, false); + context.LineTo(new Point(x + 5, _rulerRect.Top - 4), true, false); + } + geometry.Freeze(); + dc.DrawGeometry(brush, null, geometry); + dc.DrawLine(FrozenPen(color, 1.1), new Point(x, _rulerRect.Top + 2), new Point(x, _rulerRect.Bottom - 1)); + DrawText(dc, label, 7.2, semibold, color, new Point(x + 5, _rulerRect.Top - 12), dpi, 22); + } + + private Cursor HoverCursor(double x) + { + if (_rulerRect.Width <= 0 || x < _rulerRect.Left || x > _rulerRect.Right) + return Cursors.Arrow; + if (_mode == ComtradeInvestigationTimelineMode.HarmonicCursor) + return Cursors.SizeWE; + return IsNear(x, _cursor1Milliseconds) || IsNear(x, _cursor2Milliseconds) + ? Cursors.SizeWE + : Cursors.Cross; + } + + private bool IsNear(double x, double? milliseconds) + => milliseconds is { } ms && ms >= _viewStartMilliseconds && ms <= _viewEndMilliseconds && + Math.Abs(x - XForTime(ms)) <= CursorHitRadius; + + private double TimeAtX(double x) + { + var fraction = Math.Clamp((x - _rulerRect.Left) / Math.Max(1.0, _rulerRect.Width), 0.0, 1.0); + return _viewStartMilliseconds + (_viewEndMilliseconds - _viewStartMilliseconds) * fraction; + } + + private double XForTime(double milliseconds) + => _rulerRect.Left + _rulerRect.Width * + (milliseconds - _viewStartMilliseconds) / Math.Max(1e-12, _viewEndMilliseconds - _viewStartMilliseconds); + + private static string FormatRelative(double value) + => Math.Abs(value) < 0.0005 ? "0 ms" : $"{value:+0.###;-0.###} ms"; + + private static SolidColorBrush FrozenBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + + private static Pen FrozenPen(Color color, double thickness) + { + var pen = new Pen(FrozenBrush(color), thickness); + pen.Freeze(); + return pen; + } + + private static void DrawText( + DrawingContext dc, + string text, + double size, + Typeface typeface, + Color color, + Point point, + double dpi, + double maxWidth = double.PositiveInfinity) + { + var formatted = new FormattedText(text ?? string.Empty, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, size, FrozenBrush(color), dpi) + { + MaxTextWidth = double.IsFinite(maxWidth) ? Math.Max(1, maxWidth) : 10000, + Trimming = TextTrimming.CharacterEllipsis + }; + dc.DrawText(formatted, point); + } +} From 5f01051f446122b6d49f95f66c3d14c66057bd13 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:22:50 +0700 Subject: [PATCH 12/88] P1D.3: align legacy DAT time origin and proxy optimized disturbance view --- .../ComtradeDisturbanceViewP1D3ShellAware.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 Controls/ComtradeDisturbanceViewP1D3ShellAware.cs diff --git a/Controls/ComtradeDisturbanceViewP1D3ShellAware.cs b/Controls/ComtradeDisturbanceViewP1D3ShellAware.cs new file mode 100644 index 000000000..d81d08dd3 --- /dev/null +++ b/Controls/ComtradeDisturbanceViewP1D3ShellAware.cs @@ -0,0 +1,97 @@ +using System.Windows.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester.Controls; + +/// +/// Thin host around the optimized P1D.3 renderer. It reconciles CFG absolute Start/Trigger time +/// with legacy DAT files whose first raw timestamp is not zero, while keeping the renderer/host +/// source-frame coordinate system unchanged. +/// +public sealed class ComtradeDisturbanceViewP1D3ShellAware : Grid +{ + private readonly ComtradeDisturbanceViewP1D3 _inner = new(); + private bool _timeOriginInitialized; + private uint _firstRawTimestamp; + private double _timeOriginMilliseconds; + private double? _effectiveTriggerMilliseconds; + + internal event EventHandler? NavigationChanged; + internal event EventHandler? CursorChanged; + internal event EventHandler? PanRequested; + + internal double? Cursor1Milliseconds => _inner.Cursor1Milliseconds; + internal double? Cursor2Milliseconds => _inner.Cursor2Milliseconds; + internal double? CursorAMilliseconds => _inner.CursorAMilliseconds; + internal double? CursorBMilliseconds => _inner.CursorBMilliseconds; + internal double ViewStartMilliseconds => _inner.ViewStartMilliseconds; + internal double ViewEndMilliseconds => _inner.ViewEndMilliseconds; + internal double FullStartMilliseconds => _inner.FullStartMilliseconds; + internal double FullEndMilliseconds => _inner.FullEndMilliseconds; + internal double? EffectiveTriggerMilliseconds => _effectiveTriggerMilliseconds; + internal uint FirstRawTimestamp => _firstRawTimestamp; + internal double TimeOriginMilliseconds => _timeOriginMilliseconds; + + public ComtradeDisturbanceViewP1D3ShellAware() + { + Children.Add(_inner); + _inner.NavigationChanged += (_, e) => NavigationChanged?.Invoke(this, e); + _inner.CursorChanged += (_, e) => CursorChanged?.Invoke(this, e); + _inner.PanRequested += (_, e) => PanRequested?.Invoke(this, e); + _inner.ToolTip = null; + ToolTip = null; + } + + internal void ShowTracks( + IReadOnlyList tracks, + double timeMultiplier, + double? triggerMilliseconds, + bool preserveCursor = true) + { + InitializeTimeOrigin(tracks, timeMultiplier); + _effectiveTriggerMilliseconds = triggerMilliseconds is { } trigger && double.IsFinite(trigger) + ? trigger + _timeOriginMilliseconds + : null; + _inner.ShowTracks(tracks, timeMultiplier, _effectiveTriggerMilliseconds, preserveCursor); + _inner.ToolTip = null; + } + + internal void ShowMessage(string message) => _inner.ShowMessage(message); + internal void ApplyTriggerFocusedDefault(double nominalFrequencyHz) => _inner.ApplyTriggerFocusedDefault(nominalFrequencyHz); + internal void ResetToTriggerView() => _inner.ResetToTriggerView(); + internal void SetCursorAFromAbsoluteMilliseconds(double milliseconds) => _inner.SetCursorAFromAbsoluteMilliseconds(milliseconds); + internal void SetCursorFromHost(ComtradeDisturbanceCursor cursor, double milliseconds) => _inner.SetCursorFromHost(cursor, milliseconds); + internal void ResetNavigation() => _inner.ResetNavigation(); + internal void SetViewWindow(double startMilliseconds, double endMilliseconds) => _inner.SetViewWindow(startMilliseconds, endMilliseconds); + internal double PlotFractionAt(double x) => _inner.PlotFractionAt(x); + + private void InitializeTimeOrigin(IReadOnlyList tracks, double timeMultiplier) + { + if (_timeOriginInitialized || tracks.Count == 0) + return; + + foreach (var track in tracks) + { + var count = Math.Min(track.Timestamps.Length, track.SourceFrames?.Length ?? 0); + for (var index = 0; index < count; index++) + { + if (track.SourceFrames![index] != 0) continue; + _firstRawTimestamp = track.Timestamps[index]; + _timeOriginMilliseconds = ComtradeTimeMath.ToMilliseconds(_firstRawTimestamp, timeMultiplier); + _timeOriginInitialized = true; + return; + } + } + + // Initial workspace load is the full record. Preserve a conservative fallback for unusual + // decimators that omit source-frame identity while still retaining the first DAT timestamp. + var first = tracks + .Where(track => track.Timestamps.Length > 0) + .Select(track => track.Timestamps[0]) + .DefaultIfEmpty(0u) + .Min(); + _firstRawTimestamp = first; + _timeOriginMilliseconds = ComtradeTimeMath.ToMilliseconds(first, timeMultiplier); + _timeOriginInitialized = true; + } +} From f40957a3926c5970c848bb9ebd43883d7681c205 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:27:02 +0700 Subject: [PATCH 13/88] P1D.3: make investigation timeline the shared analysis context --- ComtradeWorkspaceWindow.InvestigationShell.cs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 ComtradeWorkspaceWindow.InvestigationShell.cs diff --git a/ComtradeWorkspaceWindow.InvestigationShell.cs b/ComtradeWorkspaceWindow.InvestigationShell.cs new file mode 100644 index 000000000..b3721334c --- /dev/null +++ b/ComtradeWorkspaceWindow.InvestigationShell.cs @@ -0,0 +1,186 @@ +using System.Windows; +using System.Windows.Threading; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private bool _investigationTimelineAttached; + private double? _harmonicCursorMilliseconds; + private CancellationTokenSource? _shellAnalysisRefreshCts; + + private void InvestigationTimeline_Loaded(object sender, RoutedEventArgs e) + { + if (_investigationTimelineAttached) return; + _investigationTimelineAttached = true; + + InvestigationTimeline.CursorChanged += InvestigationTimeline_CursorChanged; + DisturbanceView.NavigationChanged += DisturbanceView_ShellNavigationChanged; + DisturbanceView.CursorChanged += DisturbanceView_ShellCursorChanged; + SignalList.SelectionChanged += SignalList_ShellSelectionChanged; + Closed += InvestigationShell_Closed; + SyncInvestigationTimeline(); + } + + private void InvestigationShell_Closed(object? sender, EventArgs e) + { + _shellAnalysisRefreshCts?.Cancel(); + _shellAnalysisRefreshCts?.Dispose(); + _shellAnalysisRefreshCts = null; + if (!_investigationTimelineAttached) return; + + InvestigationTimeline.CursorChanged -= InvestigationTimeline_CursorChanged; + DisturbanceView.NavigationChanged -= DisturbanceView_ShellNavigationChanged; + DisturbanceView.CursorChanged -= DisturbanceView_ShellCursorChanged; + SignalList.SelectionChanged -= SignalList_ShellSelectionChanged; + _investigationTimelineAttached = false; + } + + private void TimeSignalsModeShell_Click(object sender, RoutedEventArgs e) + { + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.DualCursor); + SetAnalysisMode(AnalysisMode.Waveform); + SyncInvestigationTimeline(); + } + + private void PhasorModeShell_Click(object sender, RoutedEventArgs e) + { + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.DualCursor); + SetAnalysisMode(AnalysisMode.Phasor); + SyncInvestigationTimeline(); + } + + private void HarmonicsModeShell_Click(object sender, RoutedEventArgs e) + { + EnsureHarmonicCursor(); + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.HarmonicCursor); + SetAnalysisMode(AnalysisMode.Harmonics); + SyncInvestigationTimeline(); + } + + private void SignalList_ShellSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) + { + // Existing analysis logic can return to Time Signals when a non-analog row is selected. + // Reconcile the persistent ruler after that handler has completed. + Dispatcher.BeginInvoke(() => + { + if (_analysisMode != AnalysisMode.Harmonics && + InvestigationTimeline.Mode == ComtradeInvestigationTimelineMode.HarmonicCursor) + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.DualCursor); + SyncInvestigationTimeline(); + }, DispatcherPriority.Background); + } + + private void DisturbanceView_ShellNavigationChanged(object? sender, ComtradeDisturbanceNavigationChangedEventArgs e) + { + SyncInvestigationTimeline(); + } + + private void DisturbanceView_ShellCursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs e) + { + SyncInvestigationTimeline(); + if (!e.IsFinal || _analysisMode != AnalysisMode.Phasor) return; + + var selected = _phasorReferenceCursor == ComtradeDisturbanceCursor.Cursor1 + ? ComtradeInvestigationTimelineCursor.Cursor1 + : ComtradeInvestigationTimelineCursor.Cursor2; + var changed = e.Cursor == ComtradeDisturbanceCursor.Cursor1 + ? ComtradeInvestigationTimelineCursor.Cursor1 + : ComtradeInvestigationTimelineCursor.Cursor2; + if (changed == selected) + QueueShellAnalysisRefresh(immediate: true); + } + + private void InvestigationTimeline_CursorChanged(object? sender, ComtradeInvestigationTimelineCursorChangedEventArgs e) + { + switch (e.Cursor) + { + case ComtradeInvestigationTimelineCursor.Cursor1: + DisturbanceView.SetCursorFromHost(ComtradeDisturbanceCursor.Cursor1, e.AbsoluteMilliseconds); + if (e.IsFinal) + DisturbanceView_CursorChanged(DisturbanceView, + new ComtradeDisturbanceCursorChangedEventArgs( + ComtradeDisturbanceCursor.Cursor1, + e.AbsoluteMilliseconds, + e.SnapToleranceMilliseconds, + true, + false)); + if (_analysisMode == AnalysisMode.Phasor && _phasorReferenceCursor == ComtradeDisturbanceCursor.Cursor1) + QueueShellAnalysisRefresh(e.IsFinal); + break; + + case ComtradeInvestigationTimelineCursor.Cursor2: + DisturbanceView.SetCursorFromHost(ComtradeDisturbanceCursor.Cursor2, e.AbsoluteMilliseconds); + if (e.IsFinal) + DisturbanceView_CursorChanged(DisturbanceView, + new ComtradeDisturbanceCursorChangedEventArgs( + ComtradeDisturbanceCursor.Cursor2, + e.AbsoluteMilliseconds, + e.SnapToleranceMilliseconds, + true, + false)); + if (_analysisMode == AnalysisMode.Phasor && _phasorReferenceCursor == ComtradeDisturbanceCursor.Cursor2) + QueueShellAnalysisRefresh(e.IsFinal); + break; + + case ComtradeInvestigationTimelineCursor.Harmonic: + _harmonicCursorMilliseconds = e.AbsoluteMilliseconds; + if (_analysisMode == AnalysisMode.Harmonics) + QueueShellAnalysisRefresh(e.IsFinal); + break; + } + + SyncInvestigationTimeline(); + } + + private void EnsureHarmonicCursor() + { + if (_harmonicCursorMilliseconds.HasValue) return; + + _harmonicCursorMilliseconds = DisturbanceView.Cursor1Milliseconds + ?? DisturbanceView.EffectiveTriggerMilliseconds + ?? (DisturbanceView.ViewStartMilliseconds + DisturbanceView.ViewEndMilliseconds) * 0.5; + } + + private void SyncInvestigationTimeline() + { + if (!_investigationTimelineAttached) return; + if (_analysisMode == AnalysisMode.Harmonics) + EnsureHarmonicCursor(); + + InvestigationTimeline.SetContext( + DisturbanceView.FullStartMilliseconds, + DisturbanceView.FullEndMilliseconds, + DisturbanceView.ViewStartMilliseconds, + DisturbanceView.ViewEndMilliseconds, + DisturbanceView.EffectiveTriggerMilliseconds, + DisturbanceView.Cursor1Milliseconds, + DisturbanceView.Cursor2Milliseconds, + _harmonicCursorMilliseconds); + } + + private void QueueShellAnalysisRefresh(bool immediate) + { + if (_analysisMode == AnalysisMode.Waveform) return; + + _shellAnalysisRefreshCts?.Cancel(); + _shellAnalysisRefreshCts?.Dispose(); + _shellAnalysisRefreshCts = new CancellationTokenSource(); + var token = _shellAnalysisRefreshCts.Token; + _ = Dispatcher.InvokeAsync(async () => + { + try + { + if (!immediate) + await Task.Delay(90, token).ConfigureAwait(true); + if (!token.IsCancellationRequested) + await RefreshNativeAnalysisAsync().ConfigureAwait(true); + } + catch (OperationCanceledException) + { + } + }, DispatcherPriority.Background); + } +} From 6bad759b481af920a1b043259520902161a21ad8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:28:22 +0700 Subject: [PATCH 14/88] P1D.3: add persistent investigation timeline above every analysis view --- ComtradeWorkspaceWindow.xaml | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index c42ffe58b..1eb3ac760 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -143,6 +143,7 @@ + @@ -153,18 +154,16 @@ -