diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 29fe02b0..027631bc 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -457,17 +457,21 @@ private async void ServerTab_KeyDown(object sender, System.Windows.Input.KeyEven private void SetupChartContextMenus() { // Resource Overview charts - SetupChartSaveMenu(ResourceOverviewCpuChart, "CPU_Utilization", "collect.cpu_utilization_stats"); - SetupChartSaveMenu(ResourceOverviewMemoryChart, "Memory_Utilization", "collect.memory_stats"); - SetupChartSaveMenu(ResourceOverviewIoChart, "IO_Latency", "collect.file_io_stats"); - SetupChartSaveMenu(ResourceOverviewWaitChart, "Wait_Stats", "collect.wait_stats"); + Helpers.TabHelpers.SetupChartContextMenu(ResourceOverviewCpuChart, "CPU_Utilization", "collect.cpu_utilization_stats"); + Helpers.TabHelpers.SetupChartContextMenu(ResourceOverviewMemoryChart, "Memory_Utilization", "collect.memory_stats"); + Helpers.TabHelpers.SetupChartContextMenu(ResourceOverviewIoChart, "IO_Latency", "collect.file_io_stats"); + Helpers.TabHelpers.SetupChartContextMenu(ResourceOverviewWaitChart, "Wait_Stats", "collect.wait_stats"); // Blocking Stats charts - SetupChartSaveMenu(LockWaitStatsChart, "Lock_Wait_Stats", "collect.wait_stats"); - SetupChartSaveMenu(BlockingStatsBlockingEventsChart, "Blocking_Events", "collect.blocking_deadlock_stats"); - SetupChartSaveMenu(BlockingStatsDurationChart, "Blocking_Duration", "collect.blocking_deadlock_stats"); - SetupChartSaveMenu(BlockingStatsDeadlocksChart, "Deadlocks", "collect.blocking_deadlock_stats"); - SetupChartSaveMenu(BlockingStatsDeadlockWaitTimeChart, "Deadlock_Wait_Time", "collect.blocking_deadlock_stats"); + Helpers.TabHelpers.SetupChartContextMenu(LockWaitStatsChart, "Lock_Wait_Stats", "collect.wait_stats"); + Helpers.TabHelpers.SetupChartContextMenu(BlockingStatsBlockingEventsChart, "Blocking_Events", "collect.blocking_deadlock_stats"); + Helpers.TabHelpers.SetupChartContextMenu(BlockingStatsDurationChart, "Blocking_Duration", "collect.blocking_deadlock_stats"); + Helpers.TabHelpers.SetupChartContextMenu(BlockingStatsDeadlocksChart, "Deadlocks", "collect.blocking_deadlock_stats"); + Helpers.TabHelpers.SetupChartContextMenu(BlockingStatsDeadlockWaitTimeChart, "Deadlock_Wait_Time", "collect.blocking_deadlock_stats"); + + // Current Waits charts + Helpers.TabHelpers.SetupChartContextMenu(CurrentWaitsDurationChart, "Current_Waits_Duration", "collect.waiting_tasks"); + Helpers.TabHelpers.SetupChartContextMenu(CurrentWaitsBlockedChart, "Current_Waits_Blocked", "collect.waiting_tasks"); // Query Performance Trends charts now handled by QueryPerformanceContent UserControl @@ -477,279 +481,6 @@ private void SetupChartContextMenus() // Memory Analysis charts now handled by MemoryContent UserControl } - private void SetupChartSaveMenu(WpfPlot chart, string chartName, string? dataSource = null) - { - // Create native WPF context menu (simpler - no zoom items since double-click handles reset) - var contextMenu = new ContextMenu(); - - var copyItem = new MenuItem { Header = "Copy Image", Icon = new TextBlock { Text = "📋" } }; - copyItem.Click += (s, e) => - { - var tempFile = Path.Combine(Path.GetTempPath(), $"chart_copy_{Guid.NewGuid()}.png"); - try - { - chart.Plot.SavePng(tempFile, (int)chart.ActualWidth, (int)chart.ActualHeight); - var bitmap = new System.Windows.Media.Imaging.BitmapImage(); - bitmap.BeginInit(); - bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; - bitmap.UriSource = new Uri(tempFile); - bitmap.EndInit(); - bitmap.Freeze(); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(new System.Windows.DataObject(System.Windows.DataFormats.Bitmap, bitmap), false); - } - finally - { - if (File.Exists(tempFile)) File.Delete(tempFile); - } - }; - contextMenu.Items.Add(copyItem); - - var saveItem = new MenuItem { Header = "Save Image As...", Icon = new TextBlock { Text = "💾" } }; - saveItem.Click += (s, e) => - { - var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); - var defaultFileName = $"{chartName}_{timestamp}.png"; - var saveDialog = new SaveFileDialog - { - Filter = "PNG Image|*.png|JPEG Image|*.jpg|BMP Image|*.bmp", - FileName = defaultFileName, - DefaultExt = ".png" - }; - if (saveDialog.ShowDialog() == true) - { - chart.Plot.SavePng(saveDialog.FileName, (int)chart.ActualWidth, (int)chart.ActualHeight); - } - }; - contextMenu.Items.Add(saveItem); - - var openWindowItem = new MenuItem { Header = "Open in New Window", Icon = new TextBlock { Text = "🗗" } }; - openWindowItem.Click += (s, e) => - { - var newWindow = new Window - { - Title = chartName.Replace("_", " ", StringComparison.Ordinal), - Width = 800, - Height = 600 - }; - var tempFile = Path.Combine(Path.GetTempPath(), $"chart_temp_{Guid.NewGuid()}.png"); - try - { - chart.Plot.SavePng(tempFile, 800, 600); - var image = new System.Windows.Controls.Image(); - var bitmap = new System.Windows.Media.Imaging.BitmapImage(); - bitmap.BeginInit(); - bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; - bitmap.UriSource = new Uri(tempFile); - bitmap.EndInit(); - bitmap.Freeze(); - image.Source = bitmap; - newWindow.Content = image; - } - finally - { - if (File.Exists(tempFile)) File.Delete(tempFile); - } - newWindow.Show(); - }; - contextMenu.Items.Add(openWindowItem); - - contextMenu.Items.Add(new Separator()); - - var autoscaleItem = new MenuItem { Header = "Revert (or double-click)", Icon = new TextBlock { Text = "↩" } }; - autoscaleItem.Click += (s, e) => - { - chart.Plot.Axes.AutoScale(); - chart.Refresh(); - }; - contextMenu.Items.Add(autoscaleItem); - - contextMenu.Items.Add(new Separator()); - - var exportCsvItem = new MenuItem { Header = "Export Data to CSV...", Icon = new TextBlock { Text = "📊" } }; - exportCsvItem.Click += (s, e) => - { - var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); - var defaultFileName = $"{chartName}_data_{timestamp}.csv"; - var saveDialog = new SaveFileDialog - { - Filter = "CSV Files|*.csv|All Files|*.*", - FileName = defaultFileName, - DefaultExt = ".csv" - }; - if (saveDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - sb.AppendLine("DateTime,Series,Value"); - - var plottables = chart.Plot.GetPlottables(); - int seriesIndex = 1; - foreach (var plottable in plottables) - { - if (plottable is ScottPlot.Plottables.Scatter scatter) - { - var seriesName = scatter.LegendText ?? $"Series{seriesIndex}"; - var points = scatter.Data.GetScatterPoints(); - - foreach (var point in points) - { - var dateTime = DateTime.FromOADate(point.X); - sb.AppendLine(CultureInfo.InvariantCulture, $"{dateTime:yyyy-MM-dd HH:mm:ss},{TabHelpers.EscapeCsvField(seriesName)},{point.Y}"); - } - seriesIndex++; - } - } - - File.WriteAllText(saveDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported to:\n{saveDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - }; - contextMenu.Items.Add(exportCsvItem); - - // Show Data Source (if provided) - if (!string.IsNullOrEmpty(dataSource)) - { - contextMenu.Items.Add(new Separator()); - - var dataSourceItem = new MenuItem { Header = "Show Data Source", Icon = new TextBlock { Text = "ℹ" } }; - dataSourceItem.Click += (s, e) => - { - MessageBox.Show( - $"Data Source:\n\n{dataSource}", - "Chart Data Source", - MessageBoxButton.OK, - MessageBoxImage.Information); - }; - contextMenu.Items.Add(dataSourceItem); - } - - // Disable ScottPlot's default right-click context menu handling - chart.UserInputProcessor.UserActionResponses.RemoveAll(r => - r.GetType().Name.Contains("Context", StringComparison.Ordinal) || - r.GetType().Name.Contains("RightClick", StringComparison.Ordinal) || - r.GetType().Name.Contains("Menu", StringComparison.Ordinal)); - - // Use PreviewMouseRightButtonDown to show context menu before ScottPlot handles it - chart.PreviewMouseRightButtonDown += (s, e) => - { - e.Handled = true; // Prevent ScottPlot from handling - contextMenu.PlacementTarget = chart; - contextMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint; - contextMenu.IsOpen = true; - }; - - // Disable ALL of ScottPlot's default double-click behaviors - chart.UserInputProcessor.UserActionResponses.RemoveAll(r => - r.GetType().Name.Contains("DoubleClick", StringComparison.Ordinal)); - - // Use PreviewMouseDoubleClick to intercept before ScottPlot - chart.PreviewMouseDoubleClick += (s, e) => - { - e.Handled = true; - _isAutoScaling = true; - - Dispatcher.BeginInvoke(new Action(async () => - { - try - { - if (_isZoomed) - { - await ResetToOriginalRange(); - } - else - { - chart.Plot.Axes.AutoScale(); - chart.Refresh(); - } - } - finally - { - await Task.Delay(200); - _isAutoScaling = false; - } - }), System.Windows.Threading.DispatcherPriority.Background); - }; - - // Add AxisLimitsChanged handler for auto-zoom-out detection - chart.Plot.RenderManager.AxisLimitsChanged += (s, e) => - { - if (_isApplyingZoom || _isAutoScaling) return; // Avoid recursion - - // Debounce - reset timer on each change - _lastZoomedChart = chart; - - if (_chartZoomDebounceTimer == null) - { - _chartZoomDebounceTimer = new DispatcherTimer - { - Interval = TimeSpan.FromMilliseconds(800) - }; - _chartZoomDebounceTimer.Tick += ChartZoomDebounce_Tick; - } - - _chartZoomDebounceTimer.Stop(); - _chartZoomDebounceTimer.Start(); - }; - } - - private async void ChartZoomDebounce_Tick(object? sender, EventArgs e) - { - _chartZoomDebounceTimer?.Stop(); - - if (_lastZoomedChart == null || _isApplyingZoom) return; - - try - { - var limits = _lastZoomedChart.Plot.Axes.GetLimits(); - var fromDate = DateTime.FromOADate(limits.Left); - var toDate = DateTime.FromOADate(limits.Right); - - // Validate dates - if (fromDate >= toDate || fromDate.Year < 2000 || toDate.Year > 2100) - return; - - // Get current data range - DateTime currentFrom, currentTo; - if (_globalFromDate.HasValue && _globalToDate.HasValue) - { - currentFrom = _globalFromDate.Value; - currentTo = _globalToDate.Value; - } - else - { - currentTo = Helpers.ServerTimeHelper.ServerNow; - currentFrom = currentTo.AddHours(-_globalHoursBack); - } - - // Check if zoomed significantly beyond current data range (more than 10% wider) - var currentSpan = (currentTo - currentFrom).TotalMinutes; - var newSpan = (toDate - fromDate).TotalMinutes; - - bool zoomedOut = fromDate < currentFrom.AddMinutes(-currentSpan * 0.1) || - toDate > currentTo.AddMinutes(currentSpan * 0.1); - - if (zoomedOut && newSpan > currentSpan * 1.1) - { - // Auto-apply the zoomed-out range to fetch more data - _isApplyingZoom = true; - await ZoomToTimeRange(fromDate, toDate); - _isApplyingZoom = false; - } - } - catch (Exception ex) - { - // Log but don't show error for zoom operations - they're non-critical - Logger.Warning($"Chart zoom error: {ex.Message}"); - } - } - private async void ServerTab_Loaded(object sender, RoutedEventArgs e) { try @@ -814,12 +545,6 @@ private async void RefreshButton_Click(object sender, RoutedEventArgs e) private DateTime? _originalToDate = null; private bool _isZoomed = false; - // Debounce timer for auto-applying chart zoom - private DispatcherTimer? _chartZoomDebounceTimer; - private WpfPlot? _lastZoomedChart; - private bool _isApplyingZoom = false; - private bool _isAutoScaling = false; - private async void GlobalTimeRange_Click(object sender, RoutedEventArgs e) { try diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index dda0609c..57592db7 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -179,6 +179,29 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe _currentWaitsDurationHover = new Helpers.ChartHoverHelper(CurrentWaitsDurationChart, "ms"); _currentWaitsBlockedHover = new Helpers.ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); + /* Chart context menus (right-click save/export) */ + Helpers.ContextMenuHelper.SetupChartContextMenu(WaitStatsChart, "Wait_Stats"); + Helpers.ContextMenuHelper.SetupChartContextMenu(QueryDurationTrendChart, "Query_Duration_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(ProcDurationTrendChart, "Procedure_Duration_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(ExecutionCountTrendChart, "Execution_Count_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(CpuChart, "CPU_Usage"); + Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryChart, "Memory_Usage"); + Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks"); + Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing"); + Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity"); + Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoReadChart, "File_IO_Read_Latency"); + Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoWriteChart, "File_IO_Write_Latency"); + Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbChart, "TempDB_Stats"); + Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbFileIoChart, "TempDB_File_IO"); + Helpers.ContextMenuHelper.SetupChartContextMenu(LockWaitTrendChart, "Lock_Wait_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(BlockingTrendChart, "Blocking_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(DeadlockTrendChart, "Deadlock_Trends"); + Helpers.ContextMenuHelper.SetupChartContextMenu(CurrentWaitsDurationChart, "Current_Waits_Duration"); + Helpers.ContextMenuHelper.SetupChartContextMenu(CurrentWaitsBlockedChart, "Current_Waits_Blocked"); + Helpers.ContextMenuHelper.SetupChartContextMenu(PerfmonChart, "Perfmon_Counters"); + Helpers.ContextMenuHelper.SetupChartContextMenu(CollectorDurationChart, "Collector_Duration"); + /* Initial load is triggered by MainWindow.ConnectToServer calling RefreshData() after collectors finish - no Loaded handler needed */ } diff --git a/Lite/Helpers/ContextMenuHelper.cs b/Lite/Helpers/ContextMenuHelper.cs index fd97c1b2..01bfe4de 100644 --- a/Lite/Helpers/ContextMenuHelper.cs +++ b/Lite/Helpers/ContextMenuHelper.cs @@ -13,16 +13,18 @@ using System.Text; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Data; using System.Windows.Media; using Microsoft.Win32; +using ScottPlot.WPF; namespace PerformanceMonitorLite.Helpers; /// -/// Shared context menu helpers for DataGrid copy/export operations. +/// Shared context menu helpers for DataGrid copy/export and chart save/export operations. /// Used by standalone windows (history, collection log, manage servers, settings) -/// that don't have the full ServerTab context menu infrastructure. +/// and all ScottPlot chart controls. /// public static class ContextMenuHelper { @@ -174,4 +176,198 @@ private static string CsvEscape(string value, string separator) } return value; } + + /// + /// Sets up a context menu for a ScottPlot chart with standard options: + /// Copy Image, Save Image As, Open in New Window, Revert, Export Data to CSV. + /// + public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + { + var contextMenu = new ContextMenu(); + + // Copy Image + var copyItem = new MenuItem { Header = "Copy Image", Icon = new TextBlock { Text = "\U0001f4cb" } }; + copyItem.Click += (s, e) => + { + var tempFile = Path.Combine(Path.GetTempPath(), $"chart_copy_{Guid.NewGuid()}.png"); + try + { + chart.Plot.SavePng(tempFile, (int)chart.ActualWidth, (int)chart.ActualHeight); + var bitmap = new System.Windows.Media.Imaging.BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; + bitmap.UriSource = new Uri(tempFile); + bitmap.EndInit(); + bitmap.Freeze(); + Clipboard.SetDataObject(new DataObject(DataFormats.Bitmap, bitmap), false); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + }; + contextMenu.Items.Add(copyItem); + + // Save Image As + var saveItem = new MenuItem { Header = "Save Image As...", Icon = new TextBlock { Text = "\U0001f4be" } }; + saveItem.Click += (s, e) => + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); + var defaultFileName = $"{chartName}_{timestamp}.png"; + var saveDialog = new SaveFileDialog + { + Filter = "PNG Image|*.png|JPEG Image|*.jpg|BMP Image|*.bmp", + FileName = defaultFileName, + DefaultExt = ".png" + }; + if (saveDialog.ShowDialog() == true) + { + chart.Plot.SavePng(saveDialog.FileName, (int)chart.ActualWidth, (int)chart.ActualHeight); + } + }; + contextMenu.Items.Add(saveItem); + + // Open in New Window + var openWindowItem = new MenuItem { Header = "Open in New Window", Icon = new TextBlock { Text = "\U0001f5d7" } }; + openWindowItem.Click += (s, e) => + { + var newWindow = new Window + { + Title = chartName.Replace("_", " ", StringComparison.Ordinal), + Width = 800, + Height = 600 + }; + var tempFile = Path.Combine(Path.GetTempPath(), $"chart_temp_{Guid.NewGuid()}.png"); + try + { + chart.Plot.SavePng(tempFile, 800, 600); + var image = new System.Windows.Controls.Image(); + var bitmap = new System.Windows.Media.Imaging.BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; + bitmap.UriSource = new Uri(tempFile); + bitmap.EndInit(); + bitmap.Freeze(); + image.Source = bitmap; + newWindow.Content = image; + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + newWindow.Show(); + }; + contextMenu.Items.Add(openWindowItem); + + contextMenu.Items.Add(new Separator()); + + // Revert (Autoscale) + var autoscaleItem = new MenuItem { Header = "Revert (or double-click)", Icon = new TextBlock { Text = "\u21a9" } }; + autoscaleItem.Click += (s, e) => + { + chart.Plot.Axes.AutoScale(); + chart.Refresh(); + }; + contextMenu.Items.Add(autoscaleItem); + + contextMenu.Items.Add(new Separator()); + + // Export Data to CSV + var exportCsvItem = new MenuItem { Header = "Export Data to CSV...", Icon = new TextBlock { Text = "\U0001f4ca" } }; + exportCsvItem.Click += (s, e) => + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); + var defaultFileName = $"{chartName}_data_{timestamp}.csv"; + var saveDialog = new SaveFileDialog + { + Filter = "CSV Files|*.csv|All Files|*.*", + FileName = defaultFileName, + DefaultExt = ".csv" + }; + if (saveDialog.ShowDialog() == true) + { + try + { + var sb = new StringBuilder(); + var sep = App.CsvSeparator; + sb.AppendLine(string.Join(sep, new[] { "DateTime", "Series", "Value" })); + + var plottables = chart.Plot.GetPlottables(); + int seriesIndex = 1; + foreach (var plottable in plottables) + { + if (plottable is ScottPlot.Plottables.Scatter scatter) + { + var seriesName = scatter.LegendText ?? $"Series{seriesIndex}"; + var points = scatter.Data.GetScatterPoints(); + + foreach (var point in points) + { + var dateTime = DateTime.FromOADate(point.X); + sb.AppendLine(string.Join(sep, new[] + { + dateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + CsvEscape(seriesName, sep), + point.Y.ToString(CultureInfo.InvariantCulture) + })); + } + seriesIndex++; + } + } + + File.WriteAllText(saveDialog.FileName, sb.ToString(), Encoding.UTF8); + MessageBox.Show($"Data exported to:\n{saveDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + }; + contextMenu.Items.Add(exportCsvItem); + + // Show Data Source (if provided) + if (!string.IsNullOrEmpty(dataSource)) + { + contextMenu.Items.Add(new Separator()); + + var dataSourceItem = new MenuItem { Header = "Show Data Source", Icon = new TextBlock { Text = "\u2139" } }; + dataSourceItem.Click += (s, e) => + { + MessageBox.Show( + $"Data Source:\n\n{dataSource}", + "Chart Data Source", + MessageBoxButton.OK, + MessageBoxImage.Information); + }; + contextMenu.Items.Add(dataSourceItem); + } + + // Disable ScottPlot's default right-click context menu handling + chart.UserInputProcessor.UserActionResponses.RemoveAll(r => + r.GetType().Name.Contains("Context", StringComparison.Ordinal) || + r.GetType().Name.Contains("RightClick", StringComparison.Ordinal) || + r.GetType().Name.Contains("Menu", StringComparison.Ordinal)); + + // Use PreviewMouseRightButtonDown to show context menu before ScottPlot handles it + chart.PreviewMouseRightButtonDown += (s, e) => + { + e.Handled = true; + contextMenu.PlacementTarget = chart; + contextMenu.Placement = PlacementMode.MousePoint; + contextMenu.IsOpen = true; + }; + + // Disable ScottPlot's default double-click behaviors + chart.UserInputProcessor.UserActionResponses.RemoveAll(r => + r.GetType().Name.Contains("DoubleClick", StringComparison.Ordinal)); + + // Use PreviewMouseDoubleClick for revert/autoscale + chart.PreviewMouseDoubleClick += (s, e) => + { + e.Handled = true; + chart.Plot.Axes.AutoScale(); + chart.Refresh(); + }; + } }