diff --git a/Daqifi.Desktop/Helpers/MinMaxDownsampler.cs b/Daqifi.Desktop/Helpers/MinMaxDownsampler.cs new file mode 100644 index 00000000..c50733dc --- /dev/null +++ b/Daqifi.Desktop/Helpers/MinMaxDownsampler.cs @@ -0,0 +1,103 @@ +using OxyPlot; + +namespace Daqifi.Desktop.Helpers; + +/// +/// Provides efficient min/max downsampling for large time-series datasets. +/// +public static class MinMaxDownsampler +{ + /// + /// Downsamples a sorted list of data points using min/max aggregation per bucket. + /// Produces at most * 2 output points. + /// + /// Time-sorted data points to downsample. + /// Number of buckets to divide the time range into. + /// A downsampled list of data points preserving the visual envelope. + public static List Downsample(IReadOnlyList points, int bucketCount) + { + ArgumentNullException.ThrowIfNull(points); + + if (points.Count == 0 || bucketCount <= 0) + { + return []; + } + + if (points.Count <= bucketCount * 2) + { + return new List(points); + } + + var result = new List(bucketCount * 2); + + var xMin = points[0].X; + var xMax = points[points.Count - 1].X; + var xRange = xMax - xMin; + + if (xRange <= 0) + { + return [points[0]]; + } + + var bucketWidth = xRange / bucketCount; + var pointIndex = 0; + + for (var bucket = 0; bucket < bucketCount; bucket++) + { + var bucketStart = xMin + bucket * bucketWidth; + var bucketEnd = bucketStart + bucketWidth; + + var minY = double.MaxValue; + var maxY = double.MinValue; + var minYX = bucketStart; + var maxYX = bucketStart; + var hasPoints = false; + + var isLastBucket = bucket == bucketCount - 1; + while (pointIndex < points.Count && (isLastBucket || points[pointIndex].X < bucketEnd)) + { + var p = points[pointIndex]; + hasPoints = true; + + if (p.Y < minY) + { + minY = p.Y; + minYX = p.X; + } + + if (p.Y > maxY) + { + maxY = p.Y; + maxYX = p.X; + } + + pointIndex++; + } + + if (!hasPoints) + { + continue; + } + + // Emit min and max in X-order to preserve visual continuity + if (minYX <= maxYX) + { + result.Add(new DataPoint(minYX, minY)); + if (Math.Abs(minY - maxY) > double.Epsilon) + { + result.Add(new DataPoint(maxYX, maxY)); + } + } + else + { + result.Add(new DataPoint(maxYX, maxY)); + if (Math.Abs(minY - maxY) > double.Epsilon) + { + result.Add(new DataPoint(minYX, minY)); + } + } + } + + return result; + } +} diff --git a/Daqifi.Desktop/Loggers/DatabaseLogger.cs b/Daqifi.Desktop/Loggers/DatabaseLogger.cs index 746e0cac..f960687a 100644 --- a/Daqifi.Desktop/Loggers/DatabaseLogger.cs +++ b/Daqifi.Desktop/Loggers/DatabaseLogger.cs @@ -4,7 +4,9 @@ using ChannelType = Daqifi.Core.Channel.ChannelType; using Daqifi.Desktop.Device; using Daqifi.Desktop.Helpers; +using Daqifi.Desktop.View; using OxyPlot; +using OxyPlot.Annotations; using OxyPlot.Axes; using OxyPlot.Series; using System.Collections.Concurrent; @@ -32,6 +34,13 @@ public partial class LoggedSeriesLegendItem : ObservableObject [ObservableProperty] private string _deviceSerialNo; + /// + /// Truncated serial number for compact legend display (e.g., "...4104"). + /// + public string TruncatedSerialNo => _deviceSerialNo?.Length > 4 + ? $"...{_deviceSerialNo[^4..]}" + : _deviceSerialNo ?? string.Empty; + [ObservableProperty] private OxyColor _seriesColor; @@ -44,15 +53,39 @@ public bool IsVisible if (SetProperty(ref _isVisible, value) && ActualSeries != null) { ActualSeries.IsVisible = _isVisible; - Application.Current.Dispatcher.Invoke(() => _plotModel?.InvalidatePlot(true)); + Application.Current.Dispatcher.Invoke(() => + { + _plotModel?.InvalidatePlot(true); + _databaseLogger?.SetMinimapSeriesVisibility(_deviceSerialNo, _channelName, _isVisible); + }); } } } public LineSeries ActualSeries { get; } private readonly PlotModel _plotModel; + private readonly DatabaseLogger _databaseLogger; - public LoggedSeriesLegendItem(string displayName, string channelName, string deviceSerialNo, OxyColor seriesColor, bool isVisible, LineSeries actualSeries, PlotModel plotModel) + /// + /// Initializes a new legend item linked to a plot series and optional minimap sync. + /// + /// Full display name including channel and device info. + /// Channel identifier (e.g., "AI0"). + /// Device serial number for grouping. + /// Color of the associated plot series. + /// Initial visibility state of the series. + /// The OxyPlot LineSeries this legend item controls. + /// The main PlotModel to invalidate on visibility changes. + /// Optional logger for syncing minimap series visibility. + public LoggedSeriesLegendItem( + string displayName, + string channelName, + string deviceSerialNo, + OxyColor seriesColor, + bool isVisible, + LineSeries actualSeries, + PlotModel plotModel, + DatabaseLogger databaseLogger = null) { _displayName = displayName; _channelName = channelName; @@ -62,24 +95,62 @@ public LoggedSeriesLegendItem(string displayName, string channelName, string dev ActualSeries = actualSeries; ActualSeries.IsVisible = isVisible; // Ensure series visibility matches _plotModel = plotModel; + _databaseLogger = databaseLogger; } } public partial class DatabaseLogger : ObservableObject, ILogger { + #region Constants + private const int MINIMAP_BUCKET_COUNT = 800; + #endregion + #region Private Data public ObservableCollection LegendItems { get; } = new(); + public ObservableCollection DeviceLegendGroups { get; } = new(); private readonly Dictionary<(string deviceSerial, string channelName), List> _allSessionPoints = new(); private readonly BlockingCollection _buffer = new(); private readonly Dictionary<(string deviceSerial, string channelName), List> _sessionPoints = new(); + private readonly Dictionary<(string deviceSerial, string channelName), LineSeries> _minimapSeries = new(); private DateTime? _firstTime; private readonly AppLogger _appLogger = AppLogger.Instance; private readonly IDbContextFactory _loggingContext; private readonly ManualResetEventSlim _consumerGate = new(true); + private RectangleAnnotation _minimapSelectionRect; + private RectangleAnnotation _minimapDimLeft; + private RectangleAnnotation _minimapDimRight; + private MinimapInteractionController _minimapInteraction; [ObservableProperty] private PlotModel _plotModel; + + /// + /// PlotModel for the overview minimap showing downsampled data and a selection rectangle. + /// + [ObservableProperty] + private PlotModel _minimapPlotModel; + + /// + /// Controls visibility of the channel legend panel. + /// + [ObservableProperty] + private bool _isLegendPanelVisible = true; + + /// + /// Indicates whether a session with data is currently loaded. + /// Controls visibility of the minimap, legend, and empty state placeholder. + /// + [ObservableProperty] + private bool _hasSessionData; + #endregion + + #region Legend + [RelayCommand] + private void ToggleLegendPanel() + { + IsLegendPanelVisible = !IsLegendPanelVisible; + } #endregion #region Constructor @@ -150,13 +221,114 @@ public DatabaseLogger(IDbContextFactory loggingContext) PlotModel.Axes.Add(digitalAxis); PlotModel.Axes.Add(timeAxis); PlotModel.IsLegendVisible = false; // Disable the built-in legend - // PlotModel.Legends.Add(legend); // Remove legend from plot model + + // Subscribe to main time axis changes for minimap sync + timeAxis.AxisChanged += OnMainTimeAxisChanged; + + // Initialize minimap PlotModel + InitializeMinimapPlotModel(); var consumerThread = new Thread(Consumer) { IsBackground = true }; consumerThread.Start(); } #endregion + #region Minimap Initialization + private void InitializeMinimapPlotModel() + { + MinimapPlotModel = new PlotModel + { + IsLegendVisible = false, + PlotMargins = new OxyThickness(4, 2, 4, 2), + Padding = new OxyThickness(0) + }; + + var minimapTimeAxis = new LinearAxis + { + Position = AxisPosition.Bottom, + Key = "MinimapTime", + TickStyle = TickStyle.None, + MajorGridlineStyle = LineStyle.None, + MinorGridlineStyle = LineStyle.None, + TitleFontSize = 0, + FontSize = 0, + IsZoomEnabled = false, + IsPanEnabled = false + }; + + var minimapYAxis = new LinearAxis + { + Position = AxisPosition.Left, + Key = "MinimapY", + TickStyle = TickStyle.None, + MajorGridlineStyle = LineStyle.None, + MinorGridlineStyle = LineStyle.None, + TitleFontSize = 0, + FontSize = 0, + IsZoomEnabled = false, + IsPanEnabled = false, + MinimumPadding = 0.1, + MaximumPadding = 0.1 + }; + + MinimapPlotModel.Axes.Add(minimapTimeAxis); + MinimapPlotModel.Axes.Add(minimapYAxis); + + // Dim overlays for areas outside the selected range + _minimapDimLeft = new RectangleAnnotation + { + Fill = OxyColor.FromArgb(150, 200, 200, 200), + Stroke = OxyColors.Transparent, + StrokeThickness = 0, + MinimumX = -1e18, + MaximumX = 0, + MinimumY = -1e18, + MaximumY = 1e18, + Layer = AnnotationLayer.AboveSeries, + XAxisKey = "MinimapTime", + YAxisKey = "MinimapY" + }; + + _minimapDimRight = new RectangleAnnotation + { + Fill = OxyColor.FromArgb(150, 200, 200, 200), + Stroke = OxyColors.Transparent, + StrokeThickness = 0, + MinimumX = 0, + MaximumX = 1e18, + MinimumY = -1e18, + MaximumY = 1e18, + Layer = AnnotationLayer.AboveSeries, + XAxisKey = "MinimapTime", + YAxisKey = "MinimapY" + }; + + // Selection rectangle border + _minimapSelectionRect = new RectangleAnnotation + { + Fill = OxyColors.Transparent, + Stroke = OxyColor.FromRgb(0, 90, 180), + StrokeThickness = 3, + MinimumY = -1e18, + MaximumY = 1e18, + Layer = AnnotationLayer.AboveSeries, + XAxisKey = "MinimapTime", + YAxisKey = "MinimapY" + }; + + MinimapPlotModel.Annotations.Add(_minimapDimLeft); + MinimapPlotModel.Annotations.Add(_minimapDimRight); + MinimapPlotModel.Annotations.Add(_minimapSelectionRect); + + _minimapInteraction = new MinimapInteractionController( + PlotModel, + MinimapPlotModel, + _minimapSelectionRect, + _minimapDimLeft, + _minimapDimRight); + } + #endregion + /// /// Producer /// @@ -227,11 +399,18 @@ public void ClearPlot() _firstTime = null; _sessionPoints.Clear(); _allSessionPoints.Clear(); + _minimapSeries.Clear(); PlotModel.Series.Clear(); LegendItems.Clear(); + DeviceLegendGroups.Clear(); PlotModel.Title = string.Empty; PlotModel.Subtitle = string.Empty; PlotModel.InvalidatePlot(true); + + MinimapPlotModel.Series.Clear(); + MinimapPlotModel.InvalidatePlot(true); + + HasSessionData = false; }); } @@ -255,6 +434,7 @@ public void DisplayLoggingSession(LoggingSession session) var dbSamples = context.Samples.AsNoTracking() .Where(s => s.LoggingSessionID == session.ID) + .OrderBy(s => s.TimestampTicks) .Select(s => new { s.ChannelName, s.DeviceSerialNo, s.Type, s.Color, s.TimestampTicks, s.Value }) .ToList(); // Bring data into memory @@ -301,6 +481,22 @@ public void DisplayLoggingSession(LoggingSession session) } } + // Prepare downsampled minimap data on the background thread + var minimapSeriesData = new List<(string channelName, string deviceSerial, OxyColor color, List downsampled)>(); + foreach (var kvp in _allSessionPoints) + { + if (kvp.Value.Count > 0) + { + var downsampled = MinMaxDownsampler.Downsample(kvp.Value, MINIMAP_BUCKET_COUNT); + var matchingSeries = tempSeriesList.FirstOrDefault(s => + { + var parts = s.Title.Split([" : ("], StringSplitOptions.None); + return parts.Length == 2 && parts[0] == kvp.Key.channelName && parts[1].TrimEnd(')') == kvp.Key.deviceSerial; + }); + minimapSeriesData.Add((kvp.Key.channelName, kvp.Key.deviceSerial, matchingSeries?.Color ?? OxyColors.Gray, downsampled)); + } + } + // Update UI-bound collections and properties on the UI thread Application.Current.Dispatcher.Invoke(() => { @@ -312,6 +508,20 @@ public void DisplayLoggingSession(LoggingSession session) LegendItems.Add(legendItem); } + // Build grouped legend by device + DeviceLegendGroups.Clear(); + var groupDict = new Dictionary(); + foreach (var legendItem in tempLegendItemsList) + { + if (!groupDict.TryGetValue(legendItem.DeviceSerialNo, out var group)) + { + group = new DeviceLegendGroup(legendItem.DeviceSerialNo); + groupDict[legendItem.DeviceSerialNo] = group; + DeviceLegendGroups.Add(group); + } + group.Channels.Add(legendItem); + } + foreach (var series in tempSeriesList) { PlotModel.Series.Add(series); @@ -324,6 +534,41 @@ public void DisplayLoggingSession(LoggingSession session) } } + // Populate minimap with downsampled series + MinimapPlotModel.Series.Clear(); + _minimapSeries.Clear(); + foreach (var (channelName, deviceSerial, color, downsampled) in minimapSeriesData) + { + var minimapLine = new LineSeries + { + Color = color, + StrokeThickness = 1, + ItemsSource = downsampled, + XAxisKey = "MinimapTime", + YAxisKey = "MinimapY" + }; + MinimapPlotModel.Series.Add(minimapLine); + _minimapSeries[(deviceSerial, channelName)] = minimapLine; + } + + MinimapPlotModel.ResetAllAxes(); + + // Initialize selection rectangle to full data range + // Use data bounds directly since ActualMinimum/Maximum aren't set until render + if (minimapSeriesData.Count > 0) + { + var dataMinX = minimapSeriesData.Where(d => d.downsampled.Count > 0).Min(d => d.downsampled[0].X); + var dataMaxX = minimapSeriesData.Where(d => d.downsampled.Count > 0).Max(d => d.downsampled[^1].X); + _minimapSelectionRect.MinimumX = dataMinX; + _minimapSelectionRect.MaximumX = dataMaxX; + _minimapDimLeft.MaximumX = dataMinX; + _minimapDimRight.MinimumX = dataMaxX; + } + + MinimapPlotModel.InvalidatePlot(true); + + HasSessionData = tempSeriesList.Count > 0; + OnPropertyChanged("SessionPoints"); // If SessionPoints is still relevant PlotModel.InvalidatePlot(true); }); @@ -430,7 +675,8 @@ public void ResumeConsumer() newLineSeries.Color, newLineSeries.IsVisible, newLineSeries, - PlotModel); + PlotModel, + this); // LegendItems.Add(legendItem); // Removed: To be added in DisplayLoggingSession on UI thread newLineSeries.YAxisKey = type switch @@ -445,6 +691,35 @@ public void ResumeConsumer() return (newLineSeries, legendItem); } + #region Minimap Synchronization + private void OnMainTimeAxisChanged(object? sender, AxisChangedEventArgs e) + { + var timeAxis = PlotModel.Axes.FirstOrDefault(a => a.Key == "Time"); + if (timeAxis == null) + { + return; + } + + _minimapSelectionRect.MinimumX = timeAxis.ActualMinimum; + _minimapSelectionRect.MaximumX = timeAxis.ActualMaximum; + _minimapDimLeft.MaximumX = timeAxis.ActualMinimum; + _minimapDimRight.MinimumX = timeAxis.ActualMaximum; + MinimapPlotModel.InvalidatePlot(false); + } + + /// + /// Updates the visibility of a minimap series to match its main plot counterpart. + /// + public void SetMinimapSeriesVisibility(string deviceSerialNo, string channelName, bool visible) + { + if (_minimapSeries.TryGetValue((deviceSerialNo, channelName), out var series)) + { + series.IsVisible = visible; + MinimapPlotModel.InvalidatePlot(false); + } + } + #endregion + #region Commands [RelayCommand] private void SaveGraph() diff --git a/Daqifi.Desktop/Loggers/DeviceLegendGroup.cs b/Daqifi.Desktop/Loggers/DeviceLegendGroup.cs new file mode 100644 index 00000000..0ebf781a --- /dev/null +++ b/Daqifi.Desktop/Loggers/DeviceLegendGroup.cs @@ -0,0 +1,36 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System.Collections.ObjectModel; + +namespace Daqifi.Desktop.Logger; + +/// +/// Groups legend items by device for compact display in the legend panel. +/// +public partial class DeviceLegendGroup : ObservableObject +{ + /// + /// Full device serial number. + /// + public string DeviceSerialNo { get; } + + /// + /// Truncated serial for display (e.g., "...4104"). + /// + public string TruncatedSerialNo => DeviceSerialNo?.Length > 4 + ? $"...{DeviceSerialNo[^4..]}" + : DeviceSerialNo ?? string.Empty; + + /// + /// Channel legend items belonging to this device. + /// + public ObservableCollection Channels { get; } = new(); + + /// + /// Initializes a new device legend group. + /// + /// The device serial number for this group. + public DeviceLegendGroup(string deviceSerialNo) + { + DeviceSerialNo = deviceSerialNo; + } +} diff --git a/Daqifi.Desktop/MainWindow.xaml b/Daqifi.Desktop/MainWindow.xaml index 9e888699..72e47e80 100644 --- a/Daqifi.Desktop/MainWindow.xaml +++ b/Daqifi.Desktop/MainWindow.xaml @@ -466,147 +466,248 @@ - - - - - - - - - - - - + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Daqifi.Desktop/View/MinimapInteractionController.cs b/Daqifi.Desktop/View/MinimapInteractionController.cs new file mode 100644 index 00000000..9bcae8e4 --- /dev/null +++ b/Daqifi.Desktop/View/MinimapInteractionController.cs @@ -0,0 +1,328 @@ +using OxyPlot; +using OxyPlot.Annotations; +using OxyPlot.Axes; +using Cursor = System.Windows.Input.Cursor; +using Cursors = System.Windows.Input.Cursors; +using Application = System.Windows.Application; +using FrameworkElement = System.Windows.FrameworkElement; + +namespace Daqifi.Desktop.View; + +/// +/// Handles mouse interactions on the minimap PlotModel to enable drag/resize +/// of the selection rectangle, synchronized with the main plot's time axis. +/// Provides cursor feedback: resize arrows on edges, grab hand inside selection, +/// and pointer outside. +/// +public class MinimapInteractionController : IDisposable +{ + #region Private Fields + private readonly PlotModel _mainPlotModel; + private readonly PlotModel _minimapPlotModel; + private readonly RectangleAnnotation _selectionRect; + private readonly RectangleAnnotation _dimLeft; + private readonly RectangleAnnotation _dimRight; + private readonly string _mainTimeAxisKey; + private readonly string _minimapTimeAxisKey; + + private enum DragMode { None, Pan, ResizeLeft, ResizeRight } + private DragMode _dragMode = DragMode.None; + private double _dragStartDataX; + private double _dragStartRectMin; + private double _dragStartRectMax; + private Cursor _lastCursor; + + private const double EDGE_TOLERANCE_FRACTION = 0.02; + #endregion + + #region Constructor + public MinimapInteractionController( + PlotModel mainPlotModel, + PlotModel minimapPlotModel, + RectangleAnnotation selectionRect, + RectangleAnnotation dimLeft, + RectangleAnnotation dimRight, + string mainTimeAxisKey = "Time", + string minimapTimeAxisKey = "MinimapTime") + { + _mainPlotModel = mainPlotModel; + _minimapPlotModel = minimapPlotModel; + _selectionRect = selectionRect; + _dimLeft = dimLeft; + _dimRight = dimRight; + _mainTimeAxisKey = mainTimeAxisKey; + _minimapTimeAxisKey = minimapTimeAxisKey; + + _minimapPlotModel.MouseDown += OnMouseDown; + _minimapPlotModel.MouseMove += OnMouseMove; + _minimapPlotModel.MouseUp += OnMouseUp; + } + #endregion + + #region Cursor Management + /// + /// Determines the appropriate cursor based on the mouse position relative + /// to the selection rectangle edges and interior. + /// + private Cursor GetCursorForPosition(double dataX) + { + var rectMin = _selectionRect.MinimumX; + var rectMax = _selectionRect.MaximumX; + var rectRange = rectMax - rectMin; + var edgeTolerance = Math.Max(rectRange * EDGE_TOLERANCE_FRACTION, GetMinimapDataRange() * 0.005); + + if (Math.Abs(dataX - rectMin) < edgeTolerance || Math.Abs(dataX - rectMax) < edgeTolerance) + { + return Cursors.SizeWE; + } + + if (dataX >= rectMin && dataX <= rectMax) + { + return Cursors.Hand; + } + + return Cursors.Arrow; + } + + /// + /// Sets the cursor on the WPF PlotView element. Skips if unchanged from last value. + /// + private void SetCursor(Cursor cursor) + { + if (cursor == _lastCursor) + { + return; + } + + _lastCursor = cursor; + + if (_minimapPlotModel.PlotView is FrameworkElement element) + { + Application.Current?.Dispatcher.BeginInvoke(() => element.Cursor = cursor); + } + } + #endregion + + #region Mouse Handlers + private void OnMouseDown(object? sender, OxyMouseDownEventArgs e) + { + if (e.ChangedButton != OxyMouseButton.Left) + { + return; + } + + var minimapTimeAxis = GetMinimapTimeAxis(); + if (minimapTimeAxis == null) + { + return; + } + + var dataX = minimapTimeAxis.InverseTransform(e.Position.X); + var rectMin = _selectionRect.MinimumX; + var rectMax = _selectionRect.MaximumX; + var rectRange = rectMax - rectMin; + var edgeTolerance = Math.Max(rectRange * EDGE_TOLERANCE_FRACTION, GetMinimapDataRange() * 0.005); + + if (Math.Abs(dataX - rectMin) < edgeTolerance) + { + _dragMode = DragMode.ResizeLeft; + SetCursor(Cursors.SizeWE); + } + else if (Math.Abs(dataX - rectMax) < edgeTolerance) + { + _dragMode = DragMode.ResizeRight; + SetCursor(Cursors.SizeWE); + } + else if (dataX >= rectMin && dataX <= rectMax) + { + _dragMode = DragMode.Pan; + SetCursor(Cursors.ScrollAll); + } + else + { + // Click outside: jump rectangle center to click position, then start panning + var halfRange = rectRange / 2; + var fullRange = GetMinimapDataRange(); + var fullMin = GetMinimapDataMin(); + var fullMax = fullMin + fullRange; + + var newMin = Math.Max(fullMin, dataX - halfRange); + var newMax = Math.Min(fullMax, newMin + rectRange); + newMin = newMax - rectRange; + + _selectionRect.MinimumX = newMin; + _selectionRect.MaximumX = newMax; + _dimLeft.MaximumX = newMin; + _dimRight.MinimumX = newMax; + ApplyToMainPlot(newMin, newMax); + _dragMode = DragMode.Pan; + SetCursor(Cursors.ScrollAll); + } + + _dragStartDataX = dataX; + _dragStartRectMin = _selectionRect.MinimumX; + _dragStartRectMax = _selectionRect.MaximumX; + + e.Handled = true; + } + + private void OnMouseMove(object? sender, OxyMouseEventArgs e) + { + var minimapTimeAxis = GetMinimapTimeAxis(); + if (minimapTimeAxis == null) + { + return; + } + + var dataX = minimapTimeAxis.InverseTransform(e.Position.X); + + // Update cursor on hover when not dragging + if (_dragMode == DragMode.None) + { + SetCursor(GetCursorForPosition(dataX)); + return; + } + + var delta = dataX - _dragStartDataX; + var fullRange = GetMinimapDataRange(); + var fullMin = GetMinimapDataMin(); + var fullMax = fullMin + fullRange; + + double newMin, newMax; + + switch (_dragMode) + { + case DragMode.Pan: + newMin = _dragStartRectMin + delta; + newMax = _dragStartRectMax + delta; + + // Clamp to minimap bounds + if (newMin < fullMin) + { + newMax += fullMin - newMin; + newMin = fullMin; + } + if (newMax > fullMax) + { + newMin -= newMax - fullMax; + newMax = fullMax; + } + + _selectionRect.MinimumX = newMin; + _selectionRect.MaximumX = newMax; + ApplyToMainPlot(newMin, newMax); + break; + + case DragMode.ResizeLeft: + newMin = _dragStartRectMin + delta; + newMax = _dragStartRectMax; + var minWidth = fullRange * 0.005; + + if (newMin > newMax - minWidth) + { + newMin = newMax - minWidth; + } + if (newMin < fullMin) + { + newMin = fullMin; + } + + _selectionRect.MinimumX = newMin; + _selectionRect.MaximumX = newMax; + ApplyToMainPlot(newMin, newMax); + break; + + case DragMode.ResizeRight: + newMin = _dragStartRectMin; + newMax = _dragStartRectMax + delta; + minWidth = fullRange * 0.005; + + if (newMax < newMin + minWidth) + { + newMax = newMin + minWidth; + } + if (newMax > fullMax) + { + newMax = fullMax; + } + + _selectionRect.MinimumX = newMin; + _selectionRect.MaximumX = newMax; + ApplyToMainPlot(newMin, newMax); + break; + } + + e.Handled = true; + } + + private void OnMouseUp(object? sender, OxyMouseEventArgs e) + { + if (_dragMode != DragMode.None) + { + _dragMode = DragMode.None; + + // Update cursor based on final position + var minimapTimeAxis = GetMinimapTimeAxis(); + if (minimapTimeAxis != null) + { + var dataX = minimapTimeAxis.InverseTransform(e.Position.X); + SetCursor(GetCursorForPosition(dataX)); + } + + e.Handled = true; + } + } + #endregion + + #region Private Methods + private void ApplyToMainPlot(double min, double max) + { + var mainTimeAxis = _mainPlotModel.Axes.FirstOrDefault(a => a.Key == _mainTimeAxisKey); + if (mainTimeAxis == null) + { + return; + } + + mainTimeAxis.Zoom(min, max); + _dimLeft.MaximumX = min; + _dimRight.MinimumX = max; + _mainPlotModel.InvalidatePlot(false); + _minimapPlotModel.InvalidatePlot(false); + } + + private LinearAxis? GetMinimapTimeAxis() + { + return _minimapPlotModel.Axes.FirstOrDefault(a => a.Key == _minimapTimeAxisKey) as LinearAxis; + } + + private double GetMinimapDataRange() + { + var axis = GetMinimapTimeAxis(); + if (axis == null) + { + return 1; + } + + var range = axis.DataMaximum - axis.DataMinimum; + return double.IsNaN(range) || double.IsInfinity(range) || range <= 0 ? 1 : range; + } + + private double GetMinimapDataMin() + { + var axis = GetMinimapTimeAxis(); + return axis?.DataMinimum ?? 0; + } + #endregion + + #region IDisposable + /// + /// Unsubscribes all event handlers from the minimap PlotModel. + /// + public void Dispose() + { + _minimapPlotModel.MouseDown -= OnMouseDown; + _minimapPlotModel.MouseMove -= OnMouseMove; + _minimapPlotModel.MouseUp -= OnMouseUp; + } + #endregion +}