From 0004d3c72e15a27204dc0845ccd411328155d0ef Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 23 Oct 2026 16:59:40 -0400
Subject: [PATCH 1/3] Keep Viewer charts and grids readable at 1366x768 (#6652)
---
.../ViewerBlockingSourceLabelTests.cs | 2 +-
Darling/Darling.Tests/ViewerShortPlotTests.cs | 113 +++++++++
.../CorrelatedTimelineLanesControl.xaml | 18 +-
.../CorrelatedTimelineLanesControl.xaml.cs | 2 +
.../ViewerServerTab.Blocking.cs | 8 +-
.../ViewerServerTab.Charts.cs | 2 +-
.../ViewerServerTab.QueryTrends.cs | 12 +-
.../ViewerServerTab.xaml | 234 +++++++++---------
.../ViewerServerTab.xaml.cs | 5 +
PerformanceMonitor.Ui/ChartAxisTitle.cs | 107 ++++++++
PerformanceMonitor.Ui/ChartStyle.cs | 11 +
PerformanceMonitor.Ui/MinimumTickGenerator.cs | 64 +++++
12 files changed, 446 insertions(+), 132 deletions(-)
create mode 100644 Darling/Darling.Tests/ViewerShortPlotTests.cs
create mode 100644 PerformanceMonitor.Ui/ChartAxisTitle.cs
create mode 100644 PerformanceMonitor.Ui/MinimumTickGenerator.cs
diff --git a/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs b/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs
index 59e75bfa47..404fd702d3 100644
--- a/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs
+++ b/Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs
@@ -75,7 +75,7 @@ public void TheCharts_PutTheLabelOnTheAxisOnlyWhenTheyDrawRows()
var duration = Between(Tab, "private void RenderBlockingDurationChart(", "private void RenderBlockingTotalDurationChart(");
var total = Between(Tab, "private void RenderBlockingTotalDurationChart(", "private void RenderDeadlockWaitChart(");
- Assert.Single(Occurrences(trend, "BlockingSourceLabel.For(\"Blocking Incidents\", data.Select(d => d.Source))"));
+ Assert.Single(Occurrences(trend, "BlockingSourceLabel.For(\"Blocking events\", data.Select(d => d.Source))"));
Assert.Single(Occurrences(duration, "BlockingSourceLabel.For(\"Block Duration (ms)\", data.Select(d => d.Source))"));
Assert.Single(Occurrences(total, "BlockingSourceLabel.For(\"Total Block Duration (ms)\", data.Select(d => d.Source))"));
/* The zero-row branch returns before it: nothing answered, so nothing is named. */
diff --git a/Darling/Darling.Tests/ViewerShortPlotTests.cs b/Darling/Darling.Tests/ViewerShortPlotTests.cs
new file mode 100644
index 0000000000..31f3ea35b3
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerShortPlotTests.cs
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Linq;
+using PerformanceMonitor.Ui;
+using ScottPlot;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #6652: at 1366x768 the Viewer's stacked charts get short plots. A long left-axis title was cut at both ends (the unit
+/// lost) and the y axis could show only "0". The title fit and the two-tick floor are the pure rules behind the fix.
+///
+public sealed class ViewerShortPlotTests
+{
+ // The plot height a chart row at its minimum leaves for the rotated title.
+ private static double MinRowTitleSpace => ChartLayout.MinPlotHeight - ChartAxisTitle.ReservedHeight;
+
+ public static TheoryData LongTitles => new()
+ {
+ "Lock Wait Time (ms/sec)",
+ "Total Block Duration (ms)",
+ "Total Deadlock Wait (ms)",
+ "Total Wait Duration (ms)",
+ "Avg Wait Time (ms/wait)",
+ "Read Throughput (MB/s)",
+ "Write Throughput (MB/s)",
+ "Pressure Events per Hour",
+ "Slowest run per bucket (ms)",
+ "Plan Cache Size (MB)",
+ };
+
+ [Theory]
+ [MemberData(nameof(LongTitles))]
+ public void LongTitle_FitsTheMinimumRowAndKeepsItsUnit(string title)
+ {
+ var (text, size) = ChartAxisTitle.Fit(title, MinRowTitleSpace);
+
+ Assert.InRange(size, ChartAxisTitle.MinFontSize, ChartAxisTitle.BaseFontSize);
+ Assert.True(text.Length * 0.6 * size <= MinRowTitleSpace + 0.5, $"'{text}' at {size}pt is longer than {MinRowTitleSpace}px");
+ if (title.EndsWith(')'))
+ {
+ Assert.EndsWith(title[title.LastIndexOf('(')..], text);
+ }
+ }
+
+ [Fact]
+ public void ShortTitle_IsLeftAlone()
+ {
+ Assert.Equal(("CPU %", ChartAxisTitle.BaseFontSize), ChartAxisTitle.Fit("CPU %", MinRowTitleSpace));
+ }
+
+ [Fact]
+ public void LongSourceNote_IsTrimmedNotTreatedAsAUnit()
+ {
+ var (text, _) = ChartAxisTitle.Fit("Blocking events (blocked-process-report)", MinRowTitleSpace);
+ Assert.StartsWith("Blocking", text);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-5)]
+ [InlineData(double.NaN)]
+ public void UnknownSpace_ReturnsTheTitleUnchanged(double available)
+ {
+ Assert.Equal(("Lock wait ms/s", ChartAxisTitle.BaseFontSize), ChartAxisTitle.Fit("Lock wait ms/s", available));
+ }
+
+ [Fact]
+ public void AllZeroAxis_GetsAtLeastTwoLabelledTicks()
+ {
+ var ticks = MinimumTickGenerator.EnsureMinimum(System.Array.Empty(), 0, 1);
+
+ Assert.True(ticks.Count(t => t.IsMajor) >= 2);
+ Assert.Contains(ticks, t => t.Label == "0");
+ Assert.Contains(ticks, t => t.Label == "1");
+ }
+
+ [Fact]
+ public void OnlyZeroTick_GetsTheTopOfTheAxisAdded()
+ {
+ var ticks = MinimumTickGenerator.EnsureMinimum(new[] { Tick.Major(0, "0") }, 0, 1.15);
+
+ Assert.Equal(new[] { 0.0, 1.15 }, ticks.Select(t => t.Position).ToArray());
+ Assert.Equal("1.15", ticks[1].Label);
+ }
+
+ [Fact]
+ public void EnoughTicks_AreReturnedUntouched()
+ {
+ var given = new[] { Tick.Major(0, "0"), Tick.Major(50, "50"), Tick.Major(100, "100") };
+ Assert.Same(given, MinimumTickGenerator.EnsureMinimum(given, 0, 105));
+ }
+
+ [Fact]
+ public void ZeroWidthRange_StillGetsTwoTicks()
+ {
+ var ticks = MinimumTickGenerator.EnsureMinimum(System.Array.Empty(), 0, 0);
+ Assert.Equal(new[] { 0.0, 1.0 }, ticks.Select(t => t.Position).ToArray());
+ }
+
+ [Fact]
+ public void MinimumPlotHeight_IsTallEnoughForTicks()
+ {
+ Assert.InRange(ChartLayout.MinPlotHeight, 140, 200);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml
index e7456eaa50..b4d24194cf 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml
@@ -1,16 +1,19 @@
+ xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"
+ xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui">
-
+
+
+
-
-
-
-
-
+
+
+
+
+
@@ -88,4 +91,5 @@
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs
index f8a2c69e81..4aba558caa 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs
@@ -57,6 +57,8 @@ public partial class CorrelatedTimelineLanesControl : UserControl
public CorrelatedTimelineLanesControl()
{
InitializeComponent();
+ // #6652: the lanes scroll at small sizes; the wheel over a lane stays that lane's zoom.
+ PerformanceMonitor.Ui.ChartLayout.KeepWheelOnPlot(CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart);
/* No Unloaded → Dispose() handler: WPF fires Unloaded for transient
reasons (tab virtualization, layout rebuilds) and Dispose() clears
the crosshair manager's lane list, permanently breaking the crosshair
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs
index bf5273b02d..4fb4c1c320 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs
@@ -506,7 +506,7 @@ private void RenderLockWaitTrendChart(List data)
LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate());
ReapplyAxisColors(LockWaitTrendChart);
- LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)");
+ LockWaitTrendChart.Plot.YLabel("Lock wait ms/s");
SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, 1);
ShowChartLegend(LockWaitTrendChart);
LockWaitTrendChart.Refresh();
@@ -534,7 +534,7 @@ private void RenderLockWaitTrendChart(List data)
LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate());
ReapplyAxisColors(LockWaitTrendChart);
- LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)");
+ LockWaitTrendChart.Plot.YLabel("Lock wait ms/s");
SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, globalMax > 0 ? globalMax : 1);
ShowChartLegend(LockWaitTrendChart);
LockWaitTrendChart.Refresh();
@@ -563,7 +563,7 @@ private void RenderBlockingTrendChart(List data)
BlockingTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate());
ReapplyAxisColors(BlockingTrendChart);
- BlockingTrendChart.Plot.YLabel("Blocking Incidents");
+ BlockingTrendChart.Plot.YLabel("Blocking events");
SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, 1);
ShowChartLegend(BlockingTrendChart);
BlockingTrendChart.Refresh();
@@ -605,7 +605,7 @@ private void RenderBlockingTrendChart(List data)
BlockingTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate());
ReapplyAxisColors(BlockingTrendChart);
- BlockingTrendChart.Plot.YLabel(PerformanceMonitor.Ui.BlockingSourceLabel.For("Blocking Incidents", data.Select(d => d.Source)));
+ BlockingTrendChart.Plot.YLabel(PerformanceMonitor.Ui.BlockingSourceLabel.For("Blocking events", data.Select(d => d.Source)));
SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, data.Max(d => d.Count));
ShowChartLegend(BlockingTrendChart);
BlockingTrendChart.Refresh();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs
index 275b2fe336..72c5e66464 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs
@@ -324,7 +324,7 @@ private void RenderTempDbFileIoChart(List data, DateTime sta
TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
TempDbFileIoChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd);
ReapplyAxisColors(TempDbFileIoChart);
- TempDbFileIoChart.Plot.YLabel("tempdb File I/O Latency (ms)");
+ TempDbFileIoChart.Plot.YLabel("tempdb latency ms");
SetChartYLimitsWithLegendPadding(TempDbFileIoChart, 0, maxLatency > 0 ? maxLatency : 10);
ShowChartLegend(TempDbFileIoChart);
TempDbFileIoChart.Refresh();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs
index 230f034eec..618611d10e 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs
@@ -227,7 +227,7 @@ private void UpdateQueryDurationTrendChart(QueryTrendSeries series, DateTime sta
ClearChart(QueryDurationTrendChart);
ApplyTheme(QueryDurationTrendChart);
- if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration (ms/sec)"); return; }
+ if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration ms/s"); return; }
ShowTrendCoverage(QueryDurationTrendChart, series);
var rangeStart = startUtc.ToOADate();
@@ -246,7 +246,7 @@ private void UpdateQueryDurationTrendChart(QueryTrendSeries series, DateTime sta
QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
QueryDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd);
ReapplyAxisColors(QueryDurationTrendChart);
- QueryDurationTrendChart.Plot.YLabel("Duration (ms/sec)");
+ QueryDurationTrendChart.Plot.YLabel("Duration ms/s");
SetChartYLimitsWithLegendPadding(QueryDurationTrendChart, 0, values.Max());
ShowChartLegend(QueryDurationTrendChart);
QueryDurationTrendChart.Refresh();
@@ -258,7 +258,7 @@ private void UpdateProcDurationTrendChart(QueryTrendSeries series, DateTime star
ClearChart(ProcDurationTrendChart);
ApplyTheme(ProcDurationTrendChart);
- if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration (ms/sec)"); return; }
+ if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration ms/s"); return; }
ShowTrendCoverage(ProcDurationTrendChart, series);
var rangeStart = startUtc.ToOADate();
@@ -277,7 +277,7 @@ private void UpdateProcDurationTrendChart(QueryTrendSeries series, DateTime star
ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
ProcDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd);
ReapplyAxisColors(ProcDurationTrendChart);
- ProcDurationTrendChart.Plot.YLabel("Duration (ms/sec)");
+ ProcDurationTrendChart.Plot.YLabel("Duration ms/s");
SetChartYLimitsWithLegendPadding(ProcDurationTrendChart, 0, values.Max());
ShowChartLegend(ProcDurationTrendChart);
ProcDurationTrendChart.Refresh();
@@ -289,7 +289,7 @@ private void UpdateQueryStoreDurationTrendChart(QueryStoreTrendSeries series, Da
ClearChart(QueryStoreDurationTrendChart);
ApplyTheme(QueryStoreDurationTrendChart);
- if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration (ms/sec)"); return; }
+ if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration ms/s"); return; }
ShowTrendCoverage(QueryStoreDurationTrendChart, series);
var rangeStart = startUtc.ToOADate();
@@ -308,7 +308,7 @@ private void UpdateQueryStoreDurationTrendChart(QueryStoreTrendSeries series, Da
QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottomUtc(ViewerTimeHelper.CurrentDisplayZone);
QueryStoreDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd);
ReapplyAxisColors(QueryStoreDurationTrendChart);
- QueryStoreDurationTrendChart.Plot.YLabel("Duration (ms/sec)");
+ QueryStoreDurationTrendChart.Plot.YLabel("Duration ms/s");
SetChartYLimitsWithLegendPadding(QueryStoreDurationTrendChart, 0, values.Max());
ShowChartLegend(QueryStoreDurationTrendChart);
QueryStoreDurationTrendChart.Refresh();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index 0635bb0cb9..ff0885f37f 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -647,10 +647,11 @@
-
+
+
-
-
+
+
@@ -669,6 +670,7 @@
+
+
-
-
+
+
@@ -2524,6 +2527,7 @@
+
@@ -2546,11 +2550,12 @@
space usage (user/internal/version-store), total allocated size, and per-file I/O
latency. -->
-
+
+
-
-
-
+
+
+
@@ -2565,6 +2570,7 @@
+
+
-
+
-
+
-
+
@@ -2616,6 +2623,7 @@
+
-
-
+
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs
index 1ebe53cf29..9400cdc28a 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs
@@ -129,6 +129,11 @@ public ViewerServerTab(ViewerDataService dataService, DarlingServer server, View
}
}
InitializeComponent();
+ // #6652: the stacked-chart tabs scroll at small sizes; the wheel over a chart stays that chart's zoom.
+ PerformanceMonitor.Ui.ChartLayout.KeepWheelOnPlot(
+ QueryDurationTrendChart, ProcDurationTrendChart, QueryStoreDurationTrendChart, ExecutionCountTrendChart,
+ FileIoReadChart, FileIoWriteChart, TempDbChart, TempDbSizeChart, TempDbFileIoChart,
+ LockWaitTrendChart, BlockingTrendChart, DeadlockTrendChart);
/* #5565: every filtered grid below this tab keeps its column filters across a restart under this server. */
ColumnFilterScope.SetServer(this, _server.ServerName);
diff --git a/PerformanceMonitor.Ui/ChartAxisTitle.cs b/PerformanceMonitor.Ui/ChartAxisTitle.cs
new file mode 100644
index 0000000000..bc7f146a52
--- /dev/null
+++ b/PerformanceMonitor.Ui/ChartAxisTitle.cs
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Runtime.CompilerServices;
+using ScottPlot.WPF;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// #6652: a left-axis title is drawn rotated along the plot's height, so on a short plot (1366x768, three
+ /// or four stacked charts) a long title ("Lock Wait Time (ms/sec)") is cut at both ends and the unit is lost.
+ /// is the pure rule (shrink the font, then trim the name and keep the unit last);
+ /// applies it on every render, so every chart that goes through
+ /// gains it without touching its call sites.
+ ///
+ public static class ChartAxisTitle
+ {
+ public const double BaseFontSize = 12;
+ public const double MinFontSize = 9;
+
+ /// Pixels of the figure height the bottom tick labels, bottom title and padding take.
+ public const double ReservedHeight = 60;
+
+ /// Average glyph width as a fraction of the font size (sans-serif, mixed case and digits).
+ private const double GlyphWidth = 0.6;
+
+ private const int MaxUnitLength = 12;
+
+ /// The title and font size that fit ; the title unchanged when nothing is known about the space.
+ public static (string Text, double FontSize) Fit(string title, double availablePx)
+ {
+ if (string.IsNullOrEmpty(title) || !(availablePx > 0)) return (title, BaseFontSize);
+
+ for (var size = BaseFontSize; size >= MinFontSize; size -= 1)
+ {
+ if (title.Length * GlyphWidth * size <= availablePx) return (title, size);
+ }
+
+ var max = (int)Math.Floor(availablePx / (GlyphWidth * MinFontSize));
+ if (max < 4) return (title, MinFontSize);
+
+ // "Name (unit)": trim the name and keep a short unit, the part that says what the numbers are. A long
+ // parenthetical is a source note (BlockingSourceLabel), not a unit, so it is trimmed like the rest.
+ var open = title.LastIndexOf(" (", StringComparison.Ordinal);
+ if (open > 0 && title.EndsWith(')') && title.Length - open - 1 <= MaxUnitLength)
+ {
+ var unit = title[(open + 1)..];
+ var room = max - unit.Length - 2;
+ if (room >= 3) return (title[..room].TrimEnd() + "… " + unit, MinFontSize);
+ return (title[^max..], MinFontSize);
+ }
+
+ return (title[..(max - 1)].TrimEnd() + "…", MinFontSize);
+ }
+
+ private sealed class State
+ {
+ public string? Original;
+ public string? Applied;
+ }
+
+ private static readonly ConditionalWeakTable s_state = new();
+
+ /// Fits the chart's left-axis title to its height just before every render. Safe to call more than once per plot.
+ public static void Attach(WpfPlot chart)
+ {
+ var plot = chart.Plot;
+ if (s_state.TryGetValue(plot, out _)) return;
+ var state = new State();
+ s_state.Add(plot, state);
+ plot.RenderManager.RenderStarting += (_, rp) =>
+ {
+ var label = plot.Axes.Left.Label;
+ if (label.Text != state.Applied) state.Original = label.Text;
+ if (string.IsNullOrEmpty(state.Original)) { state.Applied = null; return; }
+ var (text, size) = Fit(state.Original, rp.ScaledFigureRect.Height - ReservedHeight);
+ label.Text = text;
+ label.FontSize = (float)size;
+ state.Applied = text;
+ };
+ }
+ }
+
+ /// #6652: the stacked-chart layout floor the Viewer's chart tabs share.
+ public static class ChartLayout
+ {
+ /// Smallest height a chart row may shrink to; below it the tab scrolls instead (a shorter plot cannot draw its ticks).
+ public const double MinPlotHeight = 150;
+
+ ///
+ /// A chart inside a vertical keeps the mouse wheel for its own
+ /// zoom: the wheel over the plot never also scrolls the tab (the scroll bar and the area around the plots scroll it).
+ /// Runs after ScottPlot's own wheel handler, so the zoom still happens.
+ ///
+ public static void KeepWheelOnPlot(params WpfPlot[] charts)
+ {
+ foreach (var chart in charts)
+ chart.MouseWheel += (_, e) => e.Handled = true;
+ }
+ }
+}
diff --git a/PerformanceMonitor.Ui/ChartStyle.cs b/PerformanceMonitor.Ui/ChartStyle.cs
index 7238143c8c..a253dab767 100644
--- a/PerformanceMonitor.Ui/ChartStyle.cs
+++ b/PerformanceMonitor.Ui/ChartStyle.cs
@@ -99,6 +99,8 @@ public static void ApplyThemeToChart(WpfPlot chart)
chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13;
chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13;
+ ApplyShortPlotFixes(chart);
+
// Set the WPF control Background to match so no white flash appears before ScottPlot's render loop fires
chart.Background = new SolidColorBrush(Color.FromRgb(c.FigureBackground.R, c.FigureBackground.G, c.FigureBackground.B));
@@ -125,6 +127,15 @@ public static void ApplyMinimalChartTheme(WpfPlot chart)
chart.Plot.Grid.MajorLineColor = c.Grid;
chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = c.Text;
chart.Plot.Axes.Left.TickLabelStyle.ForeColor = c.Text;
+ ApplyShortPlotFixes(chart);
+ }
+
+ /// #6652: on a short plot keep at least two y ticks and fit the left-axis title to the plot height. A custom y tick generator a caller already set is left alone.
+ private static void ApplyShortPlotFixes(WpfPlot chart)
+ {
+ if (chart.Plot.Axes.Left.TickGenerator is ScottPlot.TickGenerators.NumericAutomatic)
+ chart.Plot.Axes.Left.TickGenerator = new MinimumTickGenerator();
+ ChartAxisTitle.Attach(chart);
}
private static void HandleChartFirstLoaded(object sender, RoutedEventArgs e)
diff --git a/PerformanceMonitor.Ui/MinimumTickGenerator.cs b/PerformanceMonitor.Ui/MinimumTickGenerator.cs
new file mode 100644
index 0000000000..8142e9169f
--- /dev/null
+++ b/PerformanceMonitor.Ui/MinimumTickGenerator.cs
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Globalization;
+using System.Linq;
+
+namespace PerformanceMonitor.Ui
+{
+ ///
+ /// #6652: the automatic numeric ticks, but never fewer than two labelled ones. On a short plot, or one whose
+ /// data is all zero (axis 0..1), the stock generator can place only "0", which leaves the reader no magnitude.
+ ///
+ public sealed class MinimumTickGenerator : ScottPlot.ITickGenerator
+ {
+ private readonly ScottPlot.TickGenerators.NumericAutomatic _inner = new();
+
+ public ScottPlot.Tick[] Ticks { get; private set; } = Array.Empty();
+
+ public int MaxTickCount
+ {
+ get => _inner.MaxTickCount;
+ set => _inner.MaxTickCount = value;
+ }
+
+ public void Regenerate(
+ ScottPlot.CoordinateRange range, ScottPlot.Edge edge, ScottPlot.PixelLength size,
+ ScottPlot.Paint paint, ScottPlot.LabelStyle labelStyle)
+ {
+ _inner.Regenerate(range, edge, size, paint, labelStyle);
+ Ticks = EnsureMinimum(_inner.Ticks, range.Min, range.Max);
+ }
+
+ ///
+ /// with the axis' low and high ends added when fewer than two major ticks fall
+ /// inside ... A zero-width range gets min and min + 1.
+ ///
+ public static ScottPlot.Tick[] EnsureMinimum(ScottPlot.Tick[] ticks, double min, double max)
+ {
+ if (double.IsNaN(min) || double.IsNaN(max) || double.IsInfinity(min) || double.IsInfinity(max))
+ return ticks;
+
+ var lo = Math.Min(min, max);
+ var hi = Math.Max(min, max);
+ var inside = ticks.Where(t => t.IsMajor && t.Position >= lo && t.Position <= hi).ToList();
+ if (inside.Count >= 2) return ticks;
+
+ if (hi <= lo) hi = lo + 1;
+ var result = ticks.ToList();
+ foreach (var position in new[] { lo, hi })
+ {
+ if (inside.Any(t => Math.Abs(t.Position - position) < (hi - lo) * 1e-9)) continue;
+ result.Add(ScottPlot.Tick.Major(position, position.ToString("0.##", CultureInfo.InvariantCulture)));
+ }
+
+ return result.OrderBy(t => t.Position).ToArray();
+ }
+ }
+}
From 75f4eebc1f69d23c8f0de562b96513b0136de546 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 23 Oct 2026 17:31:51 -0400
Subject: [PATCH 2/3] Widen the Severity, Success and Abandoned columns so
their headers do not break mid-word at 1366x768 (#6652)
---
.../PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index ff0885f37f..73f2a97929 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -1768,7 +1768,7 @@
-
+
@@ -4045,7 +4045,7 @@
-
+
@@ -4054,7 +4054,7 @@
-
+
From 503910a3120d2b81cc9799b1cfb981872eb2ce5d Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 23 Oct 2026 17:56:35 -0400
Subject: [PATCH 3/3] Part of #6652: each Viewer toolbar label wraps with its
box
Each label and its box (Query tag, Compare, Auto-refresh interval, Times) is now one horizontal StackPanel item in the server tab toolbar WrapPanel, so the WrapPanel can no longer break the line between a label and its box. Element attributes, names, handlers and comments are unchanged; the other diff lines are re-indentation of the wrapped pairs.
---
.../ViewerServerTab.xaml | 74 ++++++++++---------
1 file changed, 41 insertions(+), 33 deletions(-)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index 73f2a97929..9fe68f8001 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -451,10 +451,12 @@
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+