Add plot legend with floating top-right overlay
Renders a small panel inside the plot showing each visible series as [color swatch] [sensor title] [latest value + unit]. Panel width fits the longest sensor name and redraws when sensors are added or removed from the plot (RefreshPlotSeries already fires PlotInvalidated). Falls back to ellipsizing only if the label column would push the panel past the plot bounds. Capped at 12 rows with a +N more footer; auto-hides when plot pane is narrower than 200px. Theme-aware translucent background + border match the plot frame. Persisted as showPlotLegend (default true) and toggleable from View menu and plot context menu. The View menu item is hidden when ShowPlot is off, but the setting itself is preserved across plot enable/disable and app restarts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -207,6 +207,9 @@ public sealed class PlotView : Grid
|
||||
foreach (PlotSeriesSample sample in samples)
|
||||
DrawPlotSeries(_canvas, bounds, axis, minTimestamp, maxTimestamp, sample);
|
||||
}
|
||||
|
||||
if (VM.ShowPlotLegend)
|
||||
DrawLegend(_canvas, bounds, visibleSeries);
|
||||
}
|
||||
|
||||
private MenuFlyout BuildPlotContextMenu()
|
||||
@@ -214,6 +217,7 @@ public sealed class PlotView : Grid
|
||||
MenuFlyout flyout = new();
|
||||
flyout.Items.Add(CreateToggleSettingItem("Stacked Axes", () => VM.PlotStackedAxes, value => VM.PlotStackedAxes = value));
|
||||
flyout.Items.Add(CreateToggleSettingItem("Show Axes Labels", () => VM.ShowPlotAxisLabels, value => VM.ShowPlotAxisLabels = value));
|
||||
flyout.Items.Add(CreateToggleSettingItem("Show Legend", () => VM.ShowPlotLegend, value => VM.ShowPlotLegend = value));
|
||||
|
||||
MenuFlyoutSubItem timeAxis = new() { Text = "Time Axis" };
|
||||
timeAxis.Items.Add(CreateToggleSettingItem("Enable Zoom", () => VM.PlotTimeAxisZoomEnabled, value => VM.PlotTimeAxisZoomEnabled = value));
|
||||
@@ -475,6 +479,158 @@ public sealed class PlotView : Grid
|
||||
canvas.Children.Add(label);
|
||||
}
|
||||
|
||||
private const int LegendMaxRows = 12;
|
||||
private const double LegendMinPlotWidth = 200;
|
||||
private const double LegendSwatchSize = 12;
|
||||
private const double LegendRowSpacing = 4;
|
||||
private const double LegendColumnSpacing = 10;
|
||||
private const double LegendPadding = 8;
|
||||
private const double LegendInset = 8;
|
||||
private const double LegendFontSize = 11;
|
||||
|
||||
private void DrawLegend(Canvas canvas, PlotBounds bounds, IReadOnlyList<PlotSeriesSample> visibleSeries)
|
||||
{
|
||||
if (visibleSeries.Count == 0 || bounds.Width < LegendMinPlotWidth)
|
||||
return;
|
||||
|
||||
PlotSeriesSample[] shown = visibleSeries.Take(LegendMaxRows).ToArray();
|
||||
int overflow = visibleSeries.Count - shown.Length;
|
||||
SolidColorBrush textBrush = new(GetPlotTextColor());
|
||||
|
||||
TextBlock[] labelBlocks = new TextBlock[shown.Length];
|
||||
TextBlock[] valueBlocks = new TextBlock[shown.Length];
|
||||
double[] labelDesiredWidths = new double[shown.Length];
|
||||
double valueColumnWidth = 0;
|
||||
double rowHeight = 0;
|
||||
|
||||
for (int i = 0; i < shown.Length; i++)
|
||||
{
|
||||
PlotSeriesSample sample = shown[i];
|
||||
TextBlock label = CreatePlotLabel(sample.Series.Title, textBrush, LegendFontSize);
|
||||
label.Measure(new global::Windows.Foundation.Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
labelBlocks[i] = label;
|
||||
labelDesiredWidths[i] = label.DesiredSize.Width;
|
||||
|
||||
string valueText = FormatLegendValue(sample.Points[^1].Value, sample.Series.Unit);
|
||||
TextBlock value = CreatePlotLabel(valueText, textBrush, LegendFontSize, 600);
|
||||
value.Measure(new global::Windows.Foundation.Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
valueBlocks[i] = value;
|
||||
valueColumnWidth = Math.Max(valueColumnWidth, value.DesiredSize.Width);
|
||||
|
||||
rowHeight = Math.Max(rowHeight, Math.Max(label.DesiredSize.Height, value.DesiredSize.Height));
|
||||
}
|
||||
|
||||
TextBlock? overflowBlock = null;
|
||||
if (overflow > 0)
|
||||
{
|
||||
overflowBlock = CreatePlotLabel($"+{overflow} more", textBrush, LegendFontSize);
|
||||
overflowBlock.Measure(new global::Windows.Foundation.Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
rowHeight = Math.Max(rowHeight, overflowBlock.DesiredSize.Height);
|
||||
}
|
||||
|
||||
rowHeight = Math.Max(rowHeight, LegendSwatchSize);
|
||||
|
||||
double fixedWidth = LegendPadding * 2 + LegendSwatchSize + LegendColumnSpacing + LegendColumnSpacing + valueColumnWidth;
|
||||
double maxLabelColumnWidth = Math.Max(40, bounds.Width - LegendInset * 2 - fixedWidth);
|
||||
|
||||
double labelColumnWidth = 0;
|
||||
for (int i = 0; i < shown.Length; i++)
|
||||
labelColumnWidth = Math.Max(labelColumnWidth, Math.Min(labelDesiredWidths[i], maxLabelColumnWidth));
|
||||
|
||||
bool clamped = false;
|
||||
for (int i = 0; i < shown.Length; i++)
|
||||
{
|
||||
if (labelDesiredWidths[i] > labelColumnWidth + 0.5)
|
||||
{
|
||||
clamped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clamped)
|
||||
{
|
||||
foreach (TextBlock label in labelBlocks)
|
||||
label.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||
}
|
||||
|
||||
int totalRows = shown.Length + (overflowBlock != null ? 1 : 0);
|
||||
double panelWidth = fixedWidth + labelColumnWidth;
|
||||
double panelHeight = LegendPadding * 2 + totalRows * rowHeight + Math.Max(0, totalRows - 1) * LegendRowSpacing;
|
||||
|
||||
if (panelHeight > bounds.Height - LegendInset * 2)
|
||||
return;
|
||||
|
||||
double panelLeft = bounds.Right - panelWidth - LegendInset;
|
||||
double panelTop = bounds.Top + LegendInset;
|
||||
|
||||
Rectangle background = new()
|
||||
{
|
||||
Width = panelWidth,
|
||||
Height = panelHeight,
|
||||
RadiusX = 4,
|
||||
RadiusY = 4,
|
||||
Fill = new SolidColorBrush(GetLegendBackgroundColor()),
|
||||
Stroke = new SolidColorBrush(GetPlotBorderColor()),
|
||||
StrokeThickness = 1
|
||||
};
|
||||
Canvas.SetLeft(background, panelLeft);
|
||||
Canvas.SetTop(background, panelTop);
|
||||
canvas.Children.Add(background);
|
||||
|
||||
double rowTop = panelTop + LegendPadding;
|
||||
double swatchLeft = panelLeft + LegendPadding;
|
||||
double labelLeft = swatchLeft + LegendSwatchSize + LegendColumnSpacing;
|
||||
double valueRight = panelLeft + panelWidth - LegendPadding;
|
||||
|
||||
for (int i = 0; i < shown.Length; i++)
|
||||
{
|
||||
PlotSeriesSample sample = shown[i];
|
||||
global::Windows.UI.Color seriesColor = GetVisiblePlotColor(sample.Series.Color);
|
||||
Rectangle swatch = new()
|
||||
{
|
||||
Width = LegendSwatchSize,
|
||||
Height = LegendSwatchSize,
|
||||
RadiusX = 2,
|
||||
RadiusY = 2,
|
||||
Fill = new SolidColorBrush(seriesColor)
|
||||
};
|
||||
Canvas.SetLeft(swatch, swatchLeft);
|
||||
Canvas.SetTop(swatch, rowTop + Math.Max(0, (rowHeight - LegendSwatchSize) / 2));
|
||||
canvas.Children.Add(swatch);
|
||||
|
||||
TextBlock label = labelBlocks[i];
|
||||
label.MaxWidth = labelColumnWidth;
|
||||
Canvas.SetLeft(label, labelLeft);
|
||||
Canvas.SetTop(label, rowTop + Math.Max(0, (rowHeight - label.DesiredSize.Height) / 2));
|
||||
canvas.Children.Add(label);
|
||||
|
||||
TextBlock value = valueBlocks[i];
|
||||
Canvas.SetLeft(value, valueRight - value.DesiredSize.Width);
|
||||
Canvas.SetTop(value, rowTop + Math.Max(0, (rowHeight - value.DesiredSize.Height) / 2));
|
||||
canvas.Children.Add(value);
|
||||
|
||||
rowTop += rowHeight + LegendRowSpacing;
|
||||
}
|
||||
|
||||
if (overflowBlock != null)
|
||||
{
|
||||
Canvas.SetLeft(overflowBlock, labelLeft);
|
||||
Canvas.SetTop(overflowBlock, rowTop + Math.Max(0, (rowHeight - overflowBlock.DesiredSize.Height) / 2));
|
||||
canvas.Children.Add(overflowBlock);
|
||||
}
|
||||
}
|
||||
|
||||
private global::Windows.UI.Color GetLegendBackgroundColor()
|
||||
{
|
||||
global::Windows.UI.Color background = GetPlotBackgroundColor();
|
||||
return global::Windows.UI.Color.FromArgb(220, background.R, background.G, background.B);
|
||||
}
|
||||
|
||||
private static string FormatLegendValue(double value, string unit)
|
||||
{
|
||||
string formatted = FormatPlotAxisValue(value);
|
||||
return string.IsNullOrEmpty(unit) ? formatted : $"{formatted} {unit}";
|
||||
}
|
||||
|
||||
private static void GetValueRange(IReadOnlyList<PlotSeriesSample> samples, out double minValue, out double maxValue)
|
||||
{
|
||||
minValue = double.MaxValue;
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
<MenuFlyoutSeparator />
|
||||
<ToggleMenuFlyoutItem Text="Show Hidden Sensors" IsChecked="{x:Bind ViewModel.ShowHiddenSensors, Mode=TwoWay}" />
|
||||
<ToggleMenuFlyoutItem Text="Show Plot" IsChecked="{x:Bind ViewModel.ShowPlot, Mode=TwoWay}" />
|
||||
<ToggleMenuFlyoutItem Text="Show Plot Legend"
|
||||
IsChecked="{x:Bind ViewModel.ShowPlotLegend, Mode=TwoWay}"
|
||||
Visibility="{x:Bind ViewModel.ShowPlotLegendMenuVisibility, Mode=OneWay}" />
|
||||
<ToggleMenuFlyoutItem Text="Gadget" IsChecked="{x:Bind ViewModel.ShowGadget, Mode=TwoWay}" />
|
||||
<MenuFlyoutSubItem Text="Columns">
|
||||
<ToggleMenuFlyoutItem Text="Value" IsChecked="{x:Bind ViewModel.ShowValueColumn, Mode=TwoWay}" />
|
||||
|
||||
@@ -103,6 +103,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
private bool _showMaxColumn;
|
||||
private bool _showMinColumn;
|
||||
private bool _showPlotAxisLabels;
|
||||
private bool _showPlotLegend;
|
||||
private bool _showPlot;
|
||||
private bool _showValueColumn;
|
||||
private bool _throttleAtaUpdate;
|
||||
@@ -148,6 +149,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
_plotStrokeThickness = Math.Clamp(settings.GetValue("plotStroke", 1) + 1, 1, 4);
|
||||
_plotStackedAxes = settings.GetValue("stackedAxes", true);
|
||||
_showPlotAxisLabels = settings.GetValue("showAxesLabels", true);
|
||||
_showPlotLegend = settings.GetValue("showPlotLegend", true);
|
||||
_plotTimeAxisZoomEnabled = settings.GetValue("timeAxisEnableZoom", true);
|
||||
_plotValueAxesZoomEnabled = settings.GetValue("yAxesEnableZoom", true);
|
||||
int plotTimeWindowDefault = GetPlotTimeWindowIndex(settings.GetValue("plotPanel.MaxTimeSpan", 10.0f * 60));
|
||||
@@ -187,6 +189,8 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public Visibility PlotVisibility => ShowPlot && PlotLocation != PlotLocation.Window ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility ShowPlotLegendMenuVisibility => ShowPlot ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public ObservableCollection<SensorTreeItemViewModel> RootItems { get; } = [];
|
||||
|
||||
public SensorTreeItemViewModel? SelectedItem
|
||||
@@ -601,6 +605,19 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowPlotLegend
|
||||
{
|
||||
get => _showPlotLegend;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showPlotLegend, value))
|
||||
return;
|
||||
|
||||
Settings.SetValue("showPlotLegend", value);
|
||||
PlotInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowPlot
|
||||
{
|
||||
get => _showPlot;
|
||||
@@ -611,6 +628,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IDisposable
|
||||
|
||||
Settings.SetValue("plotMenuItem", value);
|
||||
OnPropertyChanged(nameof(PlotVisibility));
|
||||
OnPropertyChanged(nameof(ShowPlotLegendMenuVisibility));
|
||||
OnPropertyChanged(nameof(IsPlotWindowVisible));
|
||||
NotifyPlotLayoutChanged();
|
||||
PlotInvalidated?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
Reference in New Issue
Block a user