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
+}