From e88f58b9d095673b17d12117856d485857460d6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 22:16:33 +0700 Subject: [PATCH 01/13] P1D: add synchronized multi-track disturbance renderer --- Controls/ComtradeDisturbanceView.cs | 494 ++++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 Controls/ComtradeDisturbanceView.cs diff --git a/Controls/ComtradeDisturbanceView.cs b/Controls/ComtradeDisturbanceView.cs new file mode 100644 index 000000000..6f8a35c9a --- /dev/null +++ b/Controls/ComtradeDisturbanceView.cs @@ -0,0 +1,494 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester.Controls; + +internal sealed record ComtradeDisturbanceTrack( + string Title, + string Subtitle, + string Units, + bool IsDigital, + double[]? Analog, + byte[]? Digital, + uint[] Timestamps, + Color StrokeColor, + bool PreserveAllPoints = false); + +internal sealed class ComtradeDisturbanceNavigationChangedEventArgs : EventArgs +{ + internal ComtradeDisturbanceNavigationChangedEventArgs(string summary) => Summary = summary; + internal string Summary { get; } +} + +public sealed class ComtradeDisturbanceView : 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 = 40.0; + private const double TrackGap = 6.0; + + private IReadOnlyList _tracks = Array.Empty(); + private double _timeMultiplier = 1.0; + private double? _triggerMilliseconds; + private double _fullStartMilliseconds; + private double _fullEndMilliseconds; + private double _viewStartMilliseconds; + private double _viewEndMilliseconds; + private double? _cursorAMilliseconds; + private double? _cursorBMilliseconds; + private Rect _lastPlot; + private bool _isPanning; + private Point _panStartPoint; + private double _panStartMilliseconds; + private double _panEndMilliseconds; + + internal event EventHandler? NavigationChanged; + + internal double? CursorAMilliseconds => _cursorAMilliseconds; + internal double? CursorBMilliseconds => _cursorBMilliseconds; + internal double ViewStartMilliseconds => _viewStartMilliseconds; + internal double ViewEndMilliseconds => _viewEndMilliseconds; + + public ComtradeDisturbanceView() + { + Focusable = true; + Cursor = Cursors.Cross; + ToolTip = "Wheel: zoom • Shift+wheel: pan • Alt+drag/middle-drag: pan • Click: Cursor A • Ctrl+click/right-click: Cursor B"; + } + + 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; + + var bounds = GetTimeBounds(_tracks); + _fullStartMilliseconds = bounds.Start; + _fullEndMilliseconds = bounds.End; + _viewStartMilliseconds = bounds.Start; + _viewEndMilliseconds = bounds.End; + if (!preserveCursor) + { + _cursorAMilliseconds = null; + _cursorBMilliseconds = null; + } + else + { + ClampCursorsToFullRange(); + } + + Height = Math.Max(330, TopMargin + BottomAxisHeight + _tracks.Sum(track => TrackHeight(track) + TrackGap)); + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void ShowMessage(string message) + { + _tracks = Array.Empty(); + _fullStartMilliseconds = 0; + _fullEndMilliseconds = 0; + _viewStartMilliseconds = 0; + _viewEndMilliseconds = 0; + _cursorAMilliseconds = null; + _cursorBMilliseconds = null; + Height = 330; + ToolTip = message; + InvalidateVisual(); + } + + internal void SetCursorAFromAbsoluteMilliseconds(double milliseconds) + { + if (!double.IsFinite(milliseconds) || _fullEndMilliseconds <= _fullStartMilliseconds) + return; + _cursorAMilliseconds = Math.Clamp(milliseconds, _fullStartMilliseconds, _fullEndMilliseconds); + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void ResetNavigation() + { + if (_fullEndMilliseconds <= _fullStartMilliseconds) + return; + _viewStartMilliseconds = _fullStartMilliseconds; + _viewEndMilliseconds = _fullEndMilliseconds; + 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; + } + + var y = TopMargin; + 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(dc, row, plot); + DrawTrackLabel(dc, track, row, dpi, body, semibold); + if (track.IsDigital) + DrawDigitalTrack(dc, track, plot, dpi, body); + else + DrawAnalogTrack(dc, track, plot, dpi, body); + y += height + TrackGap; + } + + var tracksBottom = Math.Min(bounds.Height - BottomAxisHeight, y - TrackGap); + var timelinePlot = new Rect(LabelWidth, TopMargin, plotWidth, Math.Max(1, tracksBottom - TopMargin)); + DrawTrigger(dc, timelinePlot, dpi, semibold); + DrawCursor(dc, timelinePlot, _cursorAMilliseconds, "A", Color.FromRgb(221, 142, 32), dpi, semibold); + DrawCursor(dc, timelinePlot, _cursorBMilliseconds, "B", Color.FromRgb(36, 172, 211), dpi, semibold); + DrawTimeAxis(dc, new Rect(LabelWidth, tracksBottom, plotWidth, BottomAxisHeight), dpi, body, semibold); + } + + protected override void OnMouseWheel(MouseWheelEventArgs e) + { + base.OnMouseWheel(e); + if (!_lastPlot.Contains(e.GetPosition(this)) || _viewEndMilliseconds <= _viewStartMilliseconds) + return; + + var span = _viewEndMilliseconds - _viewStartMilliseconds; + if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0) + { + var delta = span * 0.10 * (e.Delta > 0 ? -1 : 1); + SetView(_viewStartMilliseconds + delta, _viewEndMilliseconds + delta); + } + else + { + var fraction = PlotFractionAt(e.GetPosition(this).X); + var anchor = _viewStartMilliseconds + span * fraction; + var factor = e.Delta > 0 ? 0.78 : 1.28; + var nextSpan = Math.Clamp(span * factor, MinimumViewSpan(), Math.Max(MinimumViewSpan(), _fullEndMilliseconds - _fullStartMilliseconds)); + var start = anchor - nextSpan * fraction; + SetView(start, start + nextSpan); + } + + InvalidateVisual(); + RaiseNavigationChanged(); + e.Handled = true; + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + if (!_lastPlot.Contains(e.GetPosition(this)) || _viewEndMilliseconds <= _viewStartMilliseconds) + return; + + Focus(); + if (e.ClickCount >= 2 && e.ChangedButton == MouseButton.Left) + { + ResetNavigation(); + e.Handled = true; + return; + } + + var panGesture = e.ChangedButton == MouseButton.Middle || + (e.ChangedButton == MouseButton.Left && (Keyboard.Modifiers & ModifierKeys.Alt) != 0); + if (panGesture) + { + _isPanning = true; + _panStartPoint = e.GetPosition(this); + _panStartMilliseconds = _viewStartMilliseconds; + _panEndMilliseconds = _viewEndMilliseconds; + CaptureMouse(); + Cursor = Cursors.SizeWE; + e.Handled = true; + return; + } + + if (e.ChangedButton is MouseButton.Left or MouseButton.Right) + { + var time = TimeAtFraction(PlotFractionAt(e.GetPosition(this).X)); + if (e.ChangedButton == MouseButton.Right || (Keyboard.Modifiers & ModifierKeys.Control) != 0) + _cursorBMilliseconds = time; + else + _cursorAMilliseconds = time; + InvalidateVisual(); + RaiseNavigationChanged(); + e.Handled = true; + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (!_isPanning || !IsMouseCaptured || _lastPlot.Width <= 0) + return; + var span = _panEndMilliseconds - _panStartMilliseconds; + var delta = -(e.GetPosition(this).X - _panStartPoint.X) / _lastPlot.Width * span; + SetView(_panStartMilliseconds + delta, _panEndMilliseconds + delta); + InvalidateVisual(); + RaiseNavigationChanged(); + e.Handled = true; + } + + protected override void OnMouseUp(MouseButtonEventArgs e) + { + base.OnMouseUp(e); + if (!_isPanning) return; + _isPanning = false; + if (IsMouseCaptured) ReleaseMouseCapture(); + Cursor = Cursors.Cross; + e.Handled = true; + } + + protected override void OnLostMouseCapture(MouseEventArgs e) + { + base.OnLostMouseCapture(e); + _isPanning = false; + Cursor = Cursors.Cross; + } + + private void DrawTrackBackground(DrawingContext dc, Rect row, Rect plot) + { + var border = FrozenPen(Color.FromRgb(226, 232, 240), 1); + dc.DrawRectangle(new SolidColorBrush(Color.FromRgb(252, 253, 255)), null, row); + dc.DrawLine(border, new Point(0, row.Bottom), new Point(row.Right, row.Bottom)); + for (var i = 0; i <= 10; i++) + { + var x = plot.Left + plot.Width * i / 10.0; + dc.DrawLine(FrozenPen(Color.FromRgb(238, 242, 247), 1), 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) + { + var accent = new SolidColorBrush(track.StrokeColor); accent.Freeze(); + dc.DrawRoundedRectangle(accent, null, new Rect(10, row.Top + 10, 4, Math.Max(14, row.Height - 20)), 2, 2); + DrawText(dc, track.Title, 10.7, semibold, Color.FromRgb(43, 61, 82), new Point(22, row.Top + 8), dpi, maxWidth: LabelWidth - 30); + var subtitle = string.IsNullOrWhiteSpace(track.Units) ? track.Subtitle : $"{track.Subtitle} • {track.Units}"; + DrawText(dc, subtitle, 8.8, body, Color.FromRgb(119, 132, 149), new Point(22, row.Top + 27), dpi, maxWidth: LabelWidth - 30); + } + + private void DrawAnalogTrack(DrawingContext dc, ComtradeDisturbanceTrack track, Rect plot, double dpi, Typeface body) + { + 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(202, 211, 222), 1), new Point(plot.Left, zeroY), new Point(plot.Right, zeroY)); + } + + var pen = FrozenPen(track.StrokeColor, 1.15); + var geometry = new StreamGeometry(); + using (var context = geometry.Open()) + { + var maxPoints = Math.Max(80, (int)Math.Ceiling(plot.Width * 2)); + 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, pen, geometry); + + DrawText(dc, max.ToString("G4", CultureInfo.CurrentCulture), 7.8, body, Color.FromRgb(126, 139, 155), new Point(plot.Left + 4, plot.Top + 2), dpi); + DrawText(dc, min.ToString("G4", CultureInfo.CurrentCulture), 7.8, body, Color.FromRgb(126, 139, 155), new Point(plot.Left + 4, plot.Bottom - 14), dpi); + } + + 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 = new SolidColorBrush(Color.FromArgb(46, track.StrokeColor.R, track.StrokeColor.G, track.StrokeColor.B)); activeBrush.Freeze(); + var pen = FrozenPen(track.StrokeColor, 1.35); + var previousState = track.Digital[0] != 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 (previousState && clampedEnd > clampedStart) + { + var x1 = XForTime(clampedStart, plot); + var x2 = XForTime(clampedEnd, plot); + dc.DrawRectangle(activeBrush, null, new Rect(x1, plot.Top + 5, Math.Max(1, x2 - x1), plot.Height - 10)); + } + + if (i >= count) break; + var currentState = track.Digital[i] != 0; + if (currentState != previousState && endMs >= _viewStartMilliseconds && endMs <= _viewEndMilliseconds) + { + var x = XForTime(endMs, plot); + dc.DrawLine(pen, new Point(x, plot.Top + 4), new Point(x, plot.Bottom - 4)); + DrawText(dc, currentState ? "↑" : "↓", 10, body, track.StrokeColor, new Point(x + 2, plot.Top + 1), dpi); + } + previousState = currentState; + previousMs = endMs; + } + } + + 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); + var pen = FrozenPen(Color.FromRgb(217, 121, 41), 1.1); pen.DashStyle = DashStyles.Dash; + dc.DrawLine(pen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + DrawText(dc, "TRG 0", 8.5, semibold, Color.FromRgb(186, 99, 31), new Point(x + 3, plot.Top + 1), dpi); + } + + private void DrawCursor(DrawingContext dc, Rect plot, double? time, string label, Color color, double dpi, Typeface semibold) + { + if (time is not { } ms || ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) return; + var x = XForTime(ms, plot); + dc.DrawLine(FrozenPen(color, 1.2), new Point(x, plot.Top), new Point(x, plot.Bottom)); + DrawText(dc, label, 9, semibold, color, new Point(x + 3, plot.Top + 15), 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)); + for (var i = 0; i <= 10; i++) + { + var fraction = i / 10.0; + 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.2, body, Color.FromRgb(111, 125, 143), new Point(x - 15, axis.Top + 7), dpi); + } + DrawText(dc, "ms relative to trigger", 8.4, 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 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 ClampCursorsToFullRange() + { + if (_cursorAMilliseconds is { } a && (a < _fullStartMilliseconds || a > _fullEndMilliseconds)) _cursorAMilliseconds = null; + if (_cursorBMilliseconds is { } b && (b < _fullStartMilliseconds || b > _fullEndMilliseconds)) _cursorBMilliseconds = null; + } + + private void RaiseNavigationChanged() + { + var trigger = _triggerMilliseconds ?? 0.0; + var parts = new List + { + $"View {FormatRelative(_viewStartMilliseconds - trigger)} … {FormatRelative(_viewEndMilliseconds - trigger)}" + }; + if (_cursorAMilliseconds is { } a) parts.Add($"A {FormatRelative(a - trigger)}"); else parts.Add("A —"); + if (_cursorBMilliseconds is { } b) parts.Add($"B {FormatRelative(b - trigger)}"); else parts.Add("B —"); + if (_cursorAMilliseconds is { } ca && _cursorBMilliseconds is { } cb) parts.Add($"Δt {Math.Abs(cb - ca):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 Pen FrozenPen(Color color, double thickness) + { + var brush = new SolidColorBrush(color); brush.Freeze(); + var pen = new Pen(brush, 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 brush = new SolidColorBrush(color); brush.Freeze(); + var formatted = new FormattedText(text ?? string.Empty, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, size, brush, dpi) + { + MaxTextWidth = double.IsFinite(maxWidth) ? Math.Max(1, maxWidth) : 10000, + Trimming = TextTrimming.CharacterEllipsis + }; + dc.DrawText(formatted, point); + } +} \ No newline at end of file From 5cc37cf308ab679f6435b05b8580594d75a58024 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 22:18:43 +0700 Subject: [PATCH 02/13] P1D: wire multi-track signals, digital events and source navigation --- ComtradeWorkspaceWindow.Disturbance.cs | 534 +++++++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 ComtradeWorkspaceWindow.Disturbance.cs diff --git a/ComtradeWorkspaceWindow.Disturbance.cs b/ComtradeWorkspaceWindow.Disturbance.cs new file mode 100644 index 000000000..f9cc1a7e9 --- /dev/null +++ b/ComtradeWorkspaceWindow.Disturbance.cs @@ -0,0 +1,534 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const int MaxVisibleDisturbanceTracks = 16; + private readonly HashSet _disturbanceVisibleSignals = new(); + private CancellationTokenSource? _disturbanceLoadCts; + private bool _disturbanceInitialized; + private bool _disturbanceVisibilityReady; + private bool _disturbanceCheckboxSync; + private ComtradeSourceViewport _disturbanceLoadedViewport; + private ComtradeSourceViewport _disturbanceRequestedViewport; + private uint[]? _disturbanceReferenceTimestamps; + private bool _disturbanceReferenceExact; + private bool _disturbanceSourcePanGesture; + private Point _disturbanceSourcePanStartPoint; + private ComtradeSourceViewport _disturbanceSourcePanStartViewport; + + private void InitializeDisturbanceWorkspace() + { + if (_disturbanceInitialized) return; + _disturbanceInitialized = true; + + // The synchronized view replaces the old one-channel canvas as the primary waveform UX. + // Keep the old renderer alive only as a compatibility implementation detail while P1D lands. + WaveformView.NavigationChanged -= WaveformView_NavigationChanged; + WaveformView.Visibility = Visibility.Collapsed; + DisturbanceView.Visibility = _analysisMode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + DisturbanceView.NavigationChanged += DisturbanceView_NavigationChanged; + DisturbanceView.PreviewMouseWheel += DisturbanceView_PreviewMouseWheel; + DisturbanceView.PreviewMouseDown += DisturbanceView_PreviewMouseDown; + DisturbanceView.PreviewMouseUp += DisturbanceView_PreviewMouseUp; + Closed += DisturbanceWindow_Closed; + + BuildDefaultVisibleSignals(); + _disturbanceVisibilityReady = true; + Dispatcher.BeginInvoke(SyncSignalVisibilityCheckboxes, DispatcherPriority.Loaded); + + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + _disturbanceRequestedViewport = full; + _ = ReloadDisturbanceAsync(full, initialLoad: true); + } + + private void DisturbanceWindow_Closed(object? sender, EventArgs e) + { + _disturbanceLoadCts?.Cancel(); + _disturbanceLoadCts?.Dispose(); + _disturbanceLoadCts = null; + DisturbanceView.NavigationChanged -= DisturbanceView_NavigationChanged; + DisturbanceView.PreviewMouseWheel -= DisturbanceView_PreviewMouseWheel; + DisturbanceView.PreviewMouseDown -= DisturbanceView_PreviewMouseDown; + DisturbanceView.PreviewMouseUp -= DisturbanceView_PreviewMouseUp; + } + + private void BuildDefaultVisibleSignals() + { + _disturbanceVisibleSignals.Clear(); + if (SignalList.ItemsSource is not IEnumerable signals) return; + var all = signals.ToArray(); + + foreach (var signal in all.Where(item => item.IsAnalog).Take(6)) + _disturbanceVisibleSignals.Add(signal); + + var semanticDigital = all.Where(item => !item.IsAnalog && IsUsefulProtectionDigital(item.Title)).Take(6).ToArray(); + var digitalDefaults = semanticDigital.Length > 0 + ? semanticDigital + : all.Where(item => !item.IsAnalog).Take(4).ToArray(); + foreach (var signal in digitalDefaults) + _disturbanceVisibleSignals.Add(signal); + } + + private void SignalVisibility_Loaded(object sender, RoutedEventArgs e) + { + if (!_disturbanceVisibilityReady || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + _disturbanceCheckboxSync = true; + checkBox.IsChecked = _disturbanceVisibleSignals.Contains(signal); + _disturbanceCheckboxSync = false; + } + + private async void SignalVisibility_Checked(object sender, RoutedEventArgs e) + { + if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + if (_disturbanceVisibleSignals.Contains(signal)) return; + if (_disturbanceVisibleSignals.Count >= MaxVisibleDisturbanceTracks) + { + _disturbanceCheckboxSync = true; + checkBox.IsChecked = false; + _disturbanceCheckboxSync = false; + StatusTextBlock.Text = $"Waveform workspace supports up to {MaxVisibleDisturbanceTracks} visible tracks at once. Hide another signal first."; + return; + } + _disturbanceVisibleSignals.Add(signal); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + } + + private async void SignalVisibility_Unchecked(object sender, RoutedEventArgs e) + { + if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + if (!_disturbanceVisibleSignals.Remove(signal)) return; + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + } + + private async void AutoSignals_Click(object sender, RoutedEventArgs e) + { + BuildDefaultVisibleSignals(); + SyncSignalVisibilityCheckboxes(); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + } + + private async void ClearSignals_Click(object sender, RoutedEventArgs e) + { + _disturbanceVisibleSignals.Clear(); + SyncSignalVisibilityCheckboxes(); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + } + + private void SyncSignalVisibilityCheckboxes() + { + if (!_disturbanceVisibilityReady) return; + _disturbanceCheckboxSync = true; + try + { + foreach (var item in SignalList.Items.Cast()) + { + if (item is not ComtradeSignalItem signal) continue; + if (SignalList.ItemContainerGenerator.ContainerFromItem(item) is not ListBoxItem container) continue; + var checkBox = FindVisualChild(container); + if (checkBox is not null) + checkBox.IsChecked = _disturbanceVisibleSignals.Contains(signal); + } + } + finally + { + _disturbanceCheckboxSync = false; + } + } + + private static T? FindVisualChild(DependencyObject root) where T : DependencyObject + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (child is T typed) return typed; + var nested = FindVisualChild(child); + if (nested is not null) return nested; + } + return null; + } + + private async Task ReloadDisturbanceAsync(ComtradeSourceViewport requestedViewport, bool initialLoad) + { + _disturbanceRequestedViewport = requestedViewport; + _disturbanceLoadCts?.Cancel(); + _disturbanceLoadCts?.Dispose(); + _disturbanceLoadCts = new CancellationTokenSource(); + var token = _disturbanceLoadCts.Token; + var selected = _disturbanceVisibleSignals.Take(MaxVisibleDisturbanceTracks).ToArray(); + + if (selected.Length == 0) + { + DisturbanceView.ShowMessage("Select signals to display."); + DigitalEventGrid.ItemsSource = Array.Empty(); + StatusTextBlock.Text = "No waveform tracks selected • use the checkboxes in Signals or choose Auto."; + NavigationTextBlock.Text = "Shared timeline • trigger = 0 ms • click a digital event to move Cursor A"; + return; + } + + StatusTextBlock.Text = initialLoad + ? $"Building synchronized disturbance view for {selected.Length} tracks…" + : $"Refreshing {selected.Length} synchronized tracks…"; + + try + { + var result = await LoadDisturbanceTracksAsync(selected, requestedViewport, token).ConfigureAwait(true); + if (token.IsCancellationRequested) return; + + _disturbanceLoadedViewport = result.SourceViewport; + _disturbanceRequestedViewport = result.SourceViewport; + _disturbanceReferenceTimestamps = result.ReferenceTimestamps; + _disturbanceReferenceExact = result.ReferenceIsExact; + var triggerMs = ResolveTriggerMilliseconds(); + DisturbanceView.ShowTracks(result.Tracks.Select(item => item.Track).ToArray(), _record.Info.TimeMultiplier, triggerMs); + DigitalEventGrid.ItemsSource = BuildDigitalEventRows(result.Tracks, triggerMs); + DigitalEventExpander.Visibility = result.Tracks.Any(item => item.Track.IsDigital) ? Visibility.Visible : Visibility.Collapsed; + ResetViewButton.IsEnabled = result.Tracks.Any(item => item.Track.Timestamps.Length > 1); + StatusTextBlock.Text = $"Synchronized disturbance view • {result.Tracks.Count} tracks • {result.SourceViewport.FrameCount:N0} source frames" + + (result.ReferenceIsExact ? " • exact native samples" : " • bounded native overview"); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + DisturbanceView.ShowMessage(ex.Message); + StatusTextBlock.Text = $"Synchronized waveform load failed: {ex.Message}"; + } + } + + private async Task LoadDisturbanceTracksAsync( + IReadOnlyList signals, + ComtradeSourceViewport requestedViewport, + CancellationToken token) + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + try + { + return await Task.Run(() => BuildDisturbanceTracks(signals, requestedViewport, token), token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + } + + private DisturbanceLoadResult BuildDisturbanceTracks( + IReadOnlyList signals, + ComtradeSourceViewport requestedViewport, + CancellationToken token) + { + var viewport = ComtradeAbsoluteViewportMath.Normalize(requestedViewport, _record.Info.FrameCount); + var loaded = new List(signals.Count); + uint[]? referenceTimestamps = null; + var exact = viewport.FrameCount <= ExactSignalFrameLimit; + + if (exact) + { + var count = checked((int)viewport.FrameCount); + referenceTimestamps = _record.ReadRawTimestamps(viewport.StartFrame, count); + foreach (var signal in signals) + { + token.ThrowIfCancellationRequested(); + if (signal.IsAnalog) + { + var metadata = _record.AnalogChannels[checked((int)signal.Index)]; + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + BuildTrackSubtitle(metadata.Phase, metadata.Circuit), + metadata.Units, + false, + _record.ReadAnalog(signal.Index, viewport.StartFrame, count), + null, + referenceTimestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false)))); + } + else + { + var metadata = _record.StatusChannels[checked((int)signal.Index)]; + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", + "", + true, + null, + _record.ReadStatus(signal.Index, viewport.StartFrame, count), + referenceTimestamps, + ResolveSignalColor(metadata.Phase, signal.Title, true)))); + } + } + } + else + { + foreach (var signal in signals) + { + token.ThrowIfCancellationRequested(); + var preview = BuildSignalPreview(signal, viewport, token); + referenceTimestamps ??= preview.Timestamps; + if (signal.IsAnalog && preview.Analog is not null) + { + var metadata = _record.AnalogChannels[checked((int)signal.Index)]; + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, BuildTrackSubtitle(metadata.Phase, metadata.Circuit), metadata.Units, + false, preview.Analog, null, preview.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false), preserveAllPoints: true))); + } + else if (!signal.IsAnalog && preview.Status is not null) + { + var metadata = _record.StatusChannels[checked((int)signal.Index)]; + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", "", + true, null, preview.Status, preview.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, true)))); + } + } + } + + return new DisturbanceLoadResult(loaded, viewport, referenceTimestamps ?? Array.Empty(), exact); + } + + private IReadOnlyList BuildDigitalEventRows(IReadOnlyList tracks, double? triggerMilliseconds) + { + var events = new List<(double Time, string Signal, string Event, string State)>(); + foreach (var loaded in tracks.Where(item => item.Track.IsDigital)) + { + var track = loaded.Track; + if (track.Digital is null) continue; + var count = Math.Min(track.Digital.Length, track.Timestamps.Length); + 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 time = ComtradeTimeMath.ToMilliseconds(track.Timestamps[i], _record.Info.TimeMultiplier); + events.Add((time, track.Title, DescribeDigitalEvent(track.Title, after), $"{(before ? 1 : 0)}→{(after ? 1 : 0)}")); + } + } + + var ordered = events.OrderBy(item => item.Time).ToArray(); + var rows = new List(ordered.Length); + double? previous = null; + foreach (var item in ordered) + { + var relative = item.Time - (triggerMilliseconds ?? 0.0); + var delta = previous is { } previousTime ? item.Time - previousTime : (double?)null; + rows.Add(new ComtradeDigitalEventRow( + item.Time, + FormatRelativeTime(relative), + delta is { } d ? $"{d:G6} ms" : "—", + item.Signal, + item.Event, + item.State)); + previous = item.Time; + } + return rows; + } + + private void DigitalEventGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (DigitalEventGrid.SelectedItem is not ComtradeDigitalEventRow row) return; + DisturbanceView.SetCursorAFromAbsoluteMilliseconds(row.AbsoluteMilliseconds); + StatusTextBlock.Text = $"Cursor A moved to {row.Signal} • {row.Event} • {row.TimeText}."; + } + + private async void DisturbanceReset_Click(object sender, RoutedEventArgs e) + { + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + if (_record.Info.FrameCount > ExactSignalFrameLimit && _disturbanceRequestedViewport != full) + { + _disturbanceRequestedViewport = full; + await ReloadDisturbanceAsync(full, initialLoad: false).ConfigureAwait(true); + return; + } + DisturbanceView.ResetNavigation(); + } + + private void DisturbanceView_NavigationChanged(object? sender, ComtradeDisturbanceNavigationChangedEventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform) return; + NavigationTextBlock.Text = e.Summary + (_record.Info.FrameCount > ExactSignalFrameLimit + ? " | wheel reloads native source range" + : " | shared trigger-relative timeline"); + UpdateAnalysisAvailability(); + } + + private async void DisturbanceView_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) + return; + var current = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; + var fraction = DisturbanceView.PlotFractionAt(e.GetPosition(DisturbanceView).X); + ComtradeSourceViewport target; + if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0) + { + var step = Math.Max(1UL, current.FrameCount / 10); + target = ComtradeAbsoluteViewportMath.Pan(current, _record.Info.FrameCount, ToSignedStep(step, e.Delta > 0 ? -1 : 1)); + } + else + { + target = ComtradeAbsoluteViewportMath.Zoom(current, _record.Info.FrameCount, fraction, e.Delta > 0 ? 0.60 : 1.60, minimumFrames: 32); + } + e.Handled = true; + if (target == current) return; + _disturbanceRequestedViewport = target; + await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); + } + + private void DisturbanceView_PreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) return; + var pan = e.ChangedButton == MouseButton.Middle || + (e.ChangedButton == MouseButton.Left && (Keyboard.Modifiers & ModifierKeys.Alt) != 0); + if (!pan) return; + _disturbanceSourcePanGesture = true; + _disturbanceSourcePanStartPoint = e.GetPosition(DisturbanceView); + _disturbanceSourcePanStartViewport = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; + DisturbanceView.CaptureMouse(); + DisturbanceView.Cursor = Cursors.SizeWE; + e.Handled = true; + } + + private async void DisturbanceView_PreviewMouseUp(object sender, MouseButtonEventArgs e) + { + if (!_disturbanceSourcePanGesture) return; + _disturbanceSourcePanGesture = false; + var end = e.GetPosition(DisturbanceView); + if (DisturbanceView.IsMouseCaptured) DisturbanceView.ReleaseMouseCapture(); + DisturbanceView.Cursor = Cursors.Cross; + e.Handled = true; + var width = Math.Max(1.0, DisturbanceView.ActualWidth - 168.0); + var fraction = -(end.X - _disturbanceSourcePanStartPoint.X) / width; + var delta = ToSignedDelta(_disturbanceSourcePanStartViewport.FrameCount, fraction); + var target = ComtradeAbsoluteViewportMath.Pan(_disturbanceSourcePanStartViewport, _record.Info.FrameCount, delta); + if (target == _disturbanceRequestedViewport) return; + _disturbanceRequestedViewport = target; + await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); + } + + private ComtradeSourceViewport CurrentDisturbanceViewport() + => _disturbanceRequestedViewport.FrameCount > 0 + ? _disturbanceRequestedViewport + : ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + + private double? ResolveTriggerMilliseconds() + { + return ComtradeTimeMath.TryGetTriggerOffsetMilliseconds(_record.Info.StartTime, _record.Info.TriggerTime, out var trigger) + ? trigger + : null; + } + + private bool TryResolveDisturbanceCursorFrame(out ulong frame) + { + frame = 0; + if (DisturbanceView.CursorAMilliseconds is not { } cursor || _record.Info.FrameCount == 0 || _disturbanceLoadedViewport.FrameCount == 0) + return false; + + if (_disturbanceReferenceExact && _disturbanceReferenceTimestamps is { Length: > 0 } timestamps) + { + var targetRaw = cursor * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var index = NearestTimestampIndex(timestamps, targetRaw); + frame = Math.Min(_record.Info.FrameCount - 1, _disturbanceLoadedViewport.StartFrame + checked((ulong)index)); + return true; + } + + var start = DisturbanceView.ViewStartMilliseconds; + var end = DisturbanceView.ViewEndMilliseconds; + if (end <= start) return false; + var fraction = Math.Clamp((cursor - start) / (end - start), 0.0, 1.0); + var offset = _disturbanceLoadedViewport.FrameCount <= 1 + ? 0UL + : checked((ulong)Math.Round((_disturbanceLoadedViewport.FrameCount - 1) * fraction, MidpointRounding.AwayFromZero)); + frame = Math.Min(_record.Info.FrameCount - 1, _disturbanceLoadedViewport.StartFrame + offset); + return true; + } + + private static int NearestTimestampIndex(uint[] timestamps, double target) + { + if (timestamps.Length <= 1) return 0; + var lo = 0; + var hi = timestamps.Length - 1; + while (lo < hi) + { + var mid = lo + (hi - lo) / 2; + if (timestamps[mid] < target) lo = mid + 1; + else hi = mid; + } + if (lo == 0) return 0; + var before = lo - 1; + return Math.Abs(timestamps[lo] - target) < Math.Abs(timestamps[before] - target) ? lo : before; + } + + private static string BuildTrackSubtitle(string phase, string circuit) + => string.Join(" • ", new[] { string.IsNullOrWhiteSpace(phase) ? null : $"phase {phase}", circuit } + .Where(value => !string.IsNullOrWhiteSpace(value))); + + private static Color ResolveSignalColor(string phase, string title, bool digital) + { + if (digital) + { + var upper = (title ?? string.Empty).ToUpperInvariant(); + if (upper.Contains("TRIP")) return Color.FromRgb(220, 88, 55); + if (upper.Contains("PICK") || upper.Contains("START")) return Color.FromRgb(222, 142, 35); + if (upper.Contains("OPEN") || upper.Contains("CLOSE") || upper.Contains("CB")) return Color.FromRgb(37, 151, 102); + return Color.FromRgb(65, 139, 105); + } + + var normalized = NormalizePhase(phase, title); + return normalized switch + { + "L1" => Color.FromRgb(214, 66, 66), + "L2" => Color.FromRgb(218, 157, 0), + "L3" => Color.FromRgb(39, 118, 203), + "N" or "E" => Color.FromRgb(106, 117, 130), + _ => Color.FromRgb(48, 126, 213) + }; + } + + private static bool IsUsefulProtectionDigital(string title) + { + var value = (title ?? string.Empty).ToUpperInvariant(); + return new[] { "TRIP", "PICK", "START", "OPER", "OPEN", "CLOSE", "BREAKER", "CB", "52", "87", "50", "51", "21" } + .Any(value.Contains); + } + + private static string DescribeDigitalEvent(string title, bool asserted) + { + var value = (title ?? string.Empty).ToUpperInvariant(); + if (value.Contains("TRIP")) return asserted ? "Trip" : "Trip reset"; + if (value.Contains("PICK") || value.Contains("START")) return asserted ? "Pickup" : "Dropoff"; + if (value.Contains("OPEN")) return asserted ? "Open" : "Open reset"; + if (value.Contains("CLOSE")) return asserted ? "Close" : "Close reset"; + return asserted ? "Assert" : "Deassert"; + } + + private static string FormatRelativeTime(double milliseconds) + => Math.Abs(milliseconds) < 0.0005 ? "0 ms" : $"{milliseconds:+0.###;-0.###} ms"; + + private sealed record LoadedDisturbanceTrack(ComtradeSignalItem Signal, ComtradeDisturbanceTrack Track); + private sealed record DisturbanceLoadResult( + IReadOnlyList Tracks, + ComtradeSourceViewport SourceViewport, + uint[] ReferenceTimestamps, + bool ReferenceIsExact); + + private sealed record ComtradeDigitalEventRow( + double AbsoluteMilliseconds, + string TimeText, + string DeltaText, + string Signal, + string Event, + string State); +} \ No newline at end of file From 8b2633d66a9b65dcf706c745d7ed7c3e665399f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 22:19:50 +0700 Subject: [PATCH 03/13] P1D: redesign COMTRADE workspace around synchronized timeline --- ComtradeWorkspaceWindow.xaml | 198 +++++++++++++++++++++-------------- 1 file changed, 120 insertions(+), 78 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 52bbc7a3c..552017738 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -3,104 +3,120 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:ArIED61850Tester.Controls" Title="ARSAS — COMTRADE Workspace" - Width="1220" Height="760" MinWidth="920" MinHeight="560" + Width="1280" Height="800" MinWidth="980" MinHeight="620" WindowStartupLocation="CenterOwner" - Background="#F4F7FB" + Background="#F3F6FA" FontFamily="Segoe UI"> - + - - - + - + - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - + - + + + - - + + + + +