diff --git a/src/PlanViewer.App/Controls/WaitProfileBarControl.axaml.cs b/src/PlanViewer.App/Controls/WaitProfileBarControl.axaml.cs
index 61cfb81..9de88d0 100644
--- a/src/PlanViewer.App/Controls/WaitProfileBarControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/WaitProfileBarControl.axaml.cs
@@ -113,7 +113,7 @@ private void Redraw()
Canvas.SetTop(rect, 0);
BarCanvas.Children.Add(rect);
- ToolTip.SetTip(rect, $"{seg.Category}: {seg.WaitRatio:P2} ({seg.Ratio:P1} of total)");
+ ToolTip.SetTip(rect, $"{seg.Category}: {WaitRatioFormatter.Format(seg.WaitRatio)} ({seg.Ratio:P1} of total)");
var capturedCategory = seg.Category;
rect.PointerPressed += (_, e) =>
diff --git a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
index 66bd23b..d8368da 100644
--- a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
+++ b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
@@ -3,46 +3,33 @@
xmlns:local="using:PlanViewer.App.Controls"
x:Class="PlanViewer.App.Controls.WaitStatsProfileControl">
-
-
-
+ ToolTip.Tip="Show color legend"
+ VerticalAlignment="Center"
+ Click="Legend_Click"/>
+
-
-
-
-
-
-
-
-
-
+
+
_tableRows = new();
+ private Popup? _legendPopup;
public event EventHandler? CategoryClicked;
public event EventHandler? CategoryDoubleClicked;
@@ -24,10 +27,19 @@ private enum ViewMode { Bar, Ribbon, Table }
public bool IsCollapsed => _isCollapsed;
+ // All known wait categories in the order they appear in the theme
+ private static readonly string[] AllWaitCategories =
+ [
+ "Unknown", "CPU", "Worker Thread", "Lock", "Latch", "Buffer Latch",
+ "Buffer IO", "Compilation", "SQL CLR", "Mirroring", "Transaction",
+ "Preemptive", "Service Broker", "Tran Log IO", "Network IO",
+ "Parallelism", "Memory", "Tracing", "Full Text Search",
+ "Other Disk IO", "Replication", "Log Rate Governor", "Others"
+ ];
+
public WaitStatsProfileControl()
{
InitializeComponent();
- TableGrid.ItemsSource = _tableRows;
GlobalBar.CategoryClicked += (_, cat) => CategoryClicked?.Invoke(this, cat);
GlobalBar.CategoryDoubleClicked += (_, cat) => CategoryDoubleClicked?.Invoke(this, cat);
GlobalRibbon.CategoryClicked += (_, cat) => CategoryClicked?.Invoke(this, cat);
@@ -38,7 +50,6 @@ public void SetBarProfile(WaitProfile? profile)
{
_currentProfile = profile;
GlobalBar.SetProfile(profile);
- RefreshTableRows();
}
public void SetRibbonData(List data)
@@ -59,7 +70,7 @@ public void Expand()
ContentArea.IsVisible = true;
TitleText.IsVisible = true;
ToggleChartButton.IsVisible = true;
- TableViewButton.IsVisible = true;
+ LegendButton.IsVisible = true;
CollapsedChanged?.Invoke(this, false);
}
@@ -70,7 +81,7 @@ public void Collapse()
ContentArea.IsVisible = false;
TitleText.IsVisible = false;
ToggleChartButton.IsVisible = false;
- TableViewButton.IsVisible = false;
+ LegendButton.IsVisible = false;
CollapsedChanged?.Invoke(this, true);
}
@@ -81,22 +92,7 @@ public void SetLoading(bool isLoading)
private void ToggleChart_Click(object? sender, RoutedEventArgs e)
{
- // Cycle: Bar -> Ribbon -> Bar (skip table; table has its own button)
- if (_viewMode == ViewMode.Table)
- {
- // If in table mode, toggle goes back to bar
- _viewMode = ViewMode.Bar;
- }
- else
- {
- _viewMode = _viewMode == ViewMode.Bar ? ViewMode.Ribbon : ViewMode.Bar;
- }
- ApplyViewMode();
- }
-
- private void TableView_Click(object? sender, RoutedEventArgs e)
- {
- _viewMode = _viewMode == ViewMode.Table ? ViewMode.Bar : ViewMode.Table;
+ _viewMode = _viewMode == ViewMode.Bar ? ViewMode.Ribbon : ViewMode.Bar;
ApplyViewMode();
}
@@ -104,34 +100,91 @@ private void ApplyViewMode()
{
GlobalBar.IsVisible = _viewMode == ViewMode.Bar;
GlobalRibbon.IsVisible = _viewMode == ViewMode.Ribbon;
- TableGrid.IsVisible = _viewMode == ViewMode.Table;
- ToggleChartButton.Content = _viewMode == ViewMode.Ribbon ? "▤" : "☰";
-
- // The ContentArea lives inside an Auto-sized parent row, so a *-row
- // DataGrid would collapse to zero height. Give an explicit height
- // when in table mode; reset to NaN (auto) for chart modes.
- ContentArea.Height = _viewMode == ViewMode.Table ? 120 : double.NaN;
+ ToggleChartButton.Content = _viewMode == ViewMode.Ribbon ? "☰" : "▤";
}
- private void RefreshTableRows()
+ private void Legend_Click(object? sender, RoutedEventArgs e)
{
- _tableRows.Clear();
- if (_currentProfile == null) { return; }
- foreach (var seg in _currentProfile.Segments.OrderByDescending(s => s.Ratio))
+ if (_legendPopup != null)
{
- _tableRows.Add(new WaitTableRow
+ _legendPopup.IsOpen = !_legendPopup.IsOpen;
+ return;
+ }
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,Auto") };
+ for (int i = 0; i < AllWaitCategories.Length; i++)
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+
+ for (int i = 0; i < AllWaitCategories.Length; i++)
+ {
+ var cat = AllWaitCategories[i];
+ var brush = TryFindBrush($"WaitCategory.{cat}", new SolidColorBrush(Color.Parse("#555D66")));
+
+ var swatch = new Border
{
- Category = seg.Category,
- WaitRatioText = seg.WaitRatio.ToString("P2"),
- RatioText = seg.Ratio.ToString("P1")
- });
+ Width = 14,
+ Height = 14,
+ Background = brush,
+ CornerRadius = new CornerRadius(2),
+ Margin = new Thickness(4, 2),
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ Grid.SetRow(swatch, i);
+ Grid.SetColumn(swatch, 0);
+ grid.Children.Add(swatch);
+
+ var label = new TextBlock
+ {
+ Text = cat,
+ FontSize = 11,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 2, 8, 2),
+ };
+ if (this.TryFindResource("ForegroundBrush", this.ActualThemeVariant, out var fg) && fg is IBrush fgBrush)
+ label.Foreground = fgBrush;
+ Grid.SetRow(label, i);
+ Grid.SetColumn(label, 1);
+ grid.Children.Add(label);
}
+
+ var scroll = new ScrollViewer
+ {
+ Content = grid,
+ MaxHeight = 300,
+ VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
+ };
+
+ var bgBrush = TryFindBrush("BackgroundLightBrush", new SolidColorBrush(Color.Parse("#22252D")));
+ var borderBrush = TryFindBrush("BorderBrush", new SolidColorBrush(Color.Parse("#3A3D45")));
+ var container = new Border
+ {
+ Background = bgBrush,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(6),
+ Padding = new Thickness(4),
+ Child = scroll,
+ };
+
+ _legendPopup = new Popup
+ {
+ Child = container,
+ IsLightDismissEnabled = true,
+ Placement = PlacementMode.Bottom,
+ PlacementTarget = LegendButton,
+ };
+
+ // Add to visual tree so DynamicResources resolve
+ if (this.Content is Grid rootGrid)
+ rootGrid.Children.Add(_legendPopup);
+
+ _legendPopup.IsOpen = true;
}
-}
-public class WaitTableRow
-{
- public string Category { get; set; } = "";
- public string WaitRatioText { get; set; } = "";
- public string RatioText { get; set; } = "";
+ private IBrush TryFindBrush(string key, IBrush fallback)
+ {
+ if (this.TryFindResource(key, this.ActualThemeVariant, out var resource) && resource is IBrush brush)
+ return brush;
+ return fallback;
+ }
}
diff --git a/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs b/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs
index a56f1dd..5805bea 100644
--- a/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs
@@ -22,7 +22,7 @@ public partial class WaitStatsRibbonControl : UserControl
public event EventHandler? CategoryClicked;
public event EventHandler? CategoryDoubleClicked;
- private const double PaddingTop = 4;
+ private const double PaddingTop = 14; // extra room for average label
private const double PaddingBottom = 16;
public WaitStatsRibbonControl()
@@ -94,6 +94,30 @@ private void Redraw()
.ToList();
orderedCats.Add("Others");
+ // ── Vertical dashed lines at day boundaries (00:00) ────────────────
+ var dashBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#99E4E6EB")));
+ for (int i = 0; i < n; i++)
+ {
+ if (allHours[i].Hour == 0)
+ {
+ var lineX = i * stepX;
+ var line = new Line
+ {
+ StartPoint = new Point(lineX, PaddingTop),
+ EndPoint = new Point(lineX, PaddingTop + chartH),
+ Stroke = dashBrush,
+ StrokeThickness = 1,
+ StrokeDashArray = [4, 4],
+ Opacity = 0.5,
+ };
+ RibbonCanvas.Children.Add(line);
+ }
+ }
+
+ // ── Stacked bars ───────────────────────────────────────────────────
+ double totalWaitSum = 0;
+ int bucketsWithData = 0;
+
for (int i = 0; i < n; i++)
{
var hour = allHours[i];
@@ -118,6 +142,10 @@ private void Redraw()
catValues["Others"] += s.WaitRatio;
}
+ var bucketTotal = catValues.Values.Sum();
+ totalWaitSum += bucketTotal;
+ bucketsWithData++;
+
// Draw stacked from bottom
foreach (var cat in orderedCats)
{
@@ -149,7 +177,7 @@ private void Redraw()
: TimeDisplayHelper.FormatForDisplay(intervalEnd, "yyyy-MM-dd HH:mm");
var tipBlock = new TextBlock
{
- Text = $"{cat}: {ratio:P2}\n{startDisplay} \u2013 {endDisplay}",
+ Text = $"{cat}: {WaitRatioFormatter.Format(ratio)}\n{startDisplay} \u2013 {endDisplay}",
FontSize = 13,
Padding = new Thickness(6, 4),
};
@@ -169,21 +197,86 @@ private void Redraw()
}
}
- // X-axis labels
- var labelBrush = TryFindBrush("SlicerLabelBrush", new SolidColorBrush(Color.Parse("#99E4E6EB")));
- int labelInterval = Math.Max(1, n / 6);
- for (int i = 0; i < n; i += labelInterval)
+ // ── Horizontal dashed average line ─────────────────────────────────
+ if (bucketsWithData > 0)
+ {
+ var avgWait = totalWaitSum / bucketsWithData;
+ if (avgWait > 0 && avgWait <= maxTotal)
+ {
+ var avgY = PaddingTop + chartH - (avgWait / maxTotal) * chartH;
+ var avgLine = new Line
+ {
+ StartPoint = new Point(0, avgY),
+ EndPoint = new Point(w, avgY),
+ Stroke = dashBrush,
+ StrokeThickness = 1,
+ StrokeDashArray = [6, 3],
+ Opacity = 0.7,
+ };
+ RibbonCanvas.Children.Add(avgLine);
+
+ var avgLabel = new Border
+ {
+ Background = new SolidColorBrush(Color.Parse("#B0D0D0D0")),
+ CornerRadius = new CornerRadius(3),
+ Padding = new Thickness(4, 1),
+ Child = new TextBlock
+ {
+ Text = $"avg:{WaitRatioFormatter.Format(avgWait)}",
+ FontSize = 10,
+ Foreground = Brushes.Black,
+ },
+ };
+ Canvas.SetLeft(avgLabel, 2);
+ Canvas.SetTop(avgLabel, avgY - 16);
+ RibbonCanvas.Children.Add(avgLabel);
+ }
+ }
+
+ // ── X-axis labels aligned to day boundaries (00:00) ────────────────
+ var labelBrush = dashBrush;
+
+ // Collect day-boundary indices
+ var dayIndices = new List();
+ for (int i = 0; i < n; i++)
{
- var dt = allHours[i];
- var tb = new TextBlock
+ if (allHours[i].Hour == 0)
+ dayIndices.Add(i);
+ }
+
+ if (dayIndices.Count > 0)
+ {
+ foreach (var i in dayIndices)
{
- Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd HH:mm"),
- FontSize = 8,
- Foreground = labelBrush,
- };
- Canvas.SetLeft(tb, i * stepX);
- Canvas.SetTop(tb, h - PaddingBottom + 1);
- RibbonCanvas.Children.Add(tb);
+ var dt = allHours[i];
+ var tb = new TextBlock
+ {
+ Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd"),
+ FontSize = 8,
+ Foreground = labelBrush,
+ };
+ Canvas.SetLeft(tb, i * stepX + 2);
+ Canvas.SetTop(tb, h - PaddingBottom + 1);
+ RibbonCanvas.Children.Add(tb);
+ }
+ }
+ else
+ {
+ // Fallback if no day boundary exists (range < 1 day)
+ int labelInterval = Math.Max(1, n / 6);
+ for (int i = 0; i < n; i += labelInterval)
+ {
+ var dt = allHours[i];
+ var tb = new TextBlock
+ {
+ Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd HH:mm"),
+ FontSize = 8,
+ Foreground = labelBrush,
+ };
+ Canvas.SetLeft(tb, i * stepX);
+ Canvas.SetTop(tb, h - PaddingBottom + 1);
+ RibbonCanvas.Children.Add(tb);
+ }
}
}
diff --git a/src/PlanViewer.Core/Models/WaitStatsModels.cs b/src/PlanViewer.Core/Models/WaitStatsModels.cs
index 64b3282..7bcb57d 100644
--- a/src/PlanViewer.Core/Models/WaitStatsModels.cs
+++ b/src/PlanViewer.Core/Models/WaitStatsModels.cs
@@ -3,6 +3,33 @@
namespace PlanViewer.Core.Models;
+///
+/// Formats a WaitRatio value using adapted time-based units instead of percentages.
+/// WaitRatio is expressed in seconds-of-wait per second-of-wall-clock.
+/// - Below 1 s/sec → display as ms/sec (e.g. "320 ms/sec")
+/// - 1 to 60 s/sec → display as s/sec (e.g. "4.2 s/sec")
+/// - Above 60 s/sec → display as min/sec (e.g. "1.5 min/sec")
+///
+public static class WaitRatioFormatter
+{
+ public static string Format(double waitRatio)
+ {
+ if (waitRatio < 0) waitRatio = 0;
+
+ if (waitRatio < 1.0)
+ {
+ var ms = waitRatio * 1000.0;
+ return ms < 10 ? $"{ms:N1} ms/sec" : $"{ms:N0} ms/sec";
+ }
+ if (waitRatio < 60.0)
+ {
+ return $"{waitRatio:N1} s/sec";
+ }
+ var min = waitRatio / 60.0;
+ return $"{min:N1} min/sec";
+ }
+}
+
///
/// A single wait category aggregated over a time range.
/// WaitRatio = SUM(total_query_wait_time_ms) / interval_ms.