feat: redesign Bench page with Fluent card layout and radial advance monitor
Three-column layout replacing the old HMI grid: - BenchRpmCommandCard: inline numeric input, 2×4 preset grid, Start/Stop - BenchActuatorsCard: direction toggle, oil pump, temperature PID, misc relays with FluentStateToggle showing checked state via AccentFillColor - BenchLiveDataCard: 2×5 KPI tiles (RPM, P1, P2, Q-Delivery, Q-Over, temps) - BenchChartsCard: 2×2 compact chart grid (Delivery, Over, P1, P2) - AdvanceMonitorCard: RadialAngleGauge custom FrameworkElement + PSG/INJ readouts, Δ° lock offset input, Zero PSG / Zero INJ buttons Supporting changes: - AngleDisplayViewModel: promote _currentManualDegrees, _isLockSet to [ObservableProperty]; add PsgRelativeDegrees, InjEncoderDegreesValue, TargetLockAngle, IsRunningMode (29/31 hysteresis); computed PrimaryGaugeAngle, TargetAngleForGauge, SecondaryGaugeAngle - BenchControlViewModel: add IsDirectionLeft computed property, SetDirectionRightCommand, SetDirectionLeftCommand, ApplyRpmCommand - FlowmeterChartView: add IsCompact DP (false default) for 90px compact height - Styles.xaml: add IsChecked trigger to FluentStateToggle (accent fill + white text) - Strings.en/es.xaml: add all new card and actuator string keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
228
Views/Controls/RadialAngleGauge.cs
Normal file
228
Views/Controls/RadialAngleGauge.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace HC_APTBS.Views.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Geometry-rendered circular angle gauge for the bench advance-monitoring display.
|
||||
/// Shows a 360° dial with 5° tick steps, major labels every 45°, and up to three
|
||||
/// directional thumb markers: a primary thumb (manual wheel or PSG), an optional
|
||||
/// target thumb (lock-angle target, colour-coded for tolerance), and an optional
|
||||
/// ghost thumb (secondary / de-emphasised context indicator).
|
||||
/// </summary>
|
||||
public sealed class RadialAngleGauge : FrameworkElement
|
||||
{
|
||||
// ── Dependency properties ─────────────────────────────────────────────────
|
||||
|
||||
public static readonly DependencyProperty PrimaryAngleProperty =
|
||||
DependencyProperty.Register(nameof(PrimaryAngle), typeof(double), typeof(RadialAngleGauge),
|
||||
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty TargetAngleProperty =
|
||||
DependencyProperty.Register(nameof(TargetAngle), typeof(double?), typeof(RadialAngleGauge),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty SecondaryAngleProperty =
|
||||
DependencyProperty.Register(nameof(SecondaryAngle), typeof(double?), typeof(RadialAngleGauge),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty PrimaryBrushProperty =
|
||||
DependencyProperty.Register(nameof(PrimaryBrush), typeof(Brush), typeof(RadialAngleGauge),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty TargetBrushProperty =
|
||||
DependencyProperty.Register(nameof(TargetBrush), typeof(Brush), typeof(RadialAngleGauge),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty IsRunningModeProperty =
|
||||
DependencyProperty.Register(nameof(IsRunningMode), typeof(bool), typeof(RadialAngleGauge),
|
||||
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
// ── Property accessors ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Primary thumb angle in degrees (0 = top, clockwise positive).</summary>
|
||||
public double PrimaryAngle
|
||||
{
|
||||
get => (double)GetValue(PrimaryAngleProperty);
|
||||
set => SetValue(PrimaryAngleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Target thumb angle in degrees, or null to hide it.</summary>
|
||||
public double? TargetAngle
|
||||
{
|
||||
get => (double?)GetValue(TargetAngleProperty);
|
||||
set => SetValue(TargetAngleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Ghost secondary thumb angle in degrees, or null to hide it.</summary>
|
||||
public double? SecondaryAngle
|
||||
{
|
||||
get => (double?)GetValue(SecondaryAngleProperty);
|
||||
set => SetValue(SecondaryAngleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Fill brush for the primary thumb. Falls back to the accent brush when null.</summary>
|
||||
public Brush? PrimaryBrush
|
||||
{
|
||||
get => (Brush?)GetValue(PrimaryBrushProperty);
|
||||
set => SetValue(PrimaryBrushProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Fill brush for the target thumb (green = within tolerance, red = off-target).</summary>
|
||||
public Brush? TargetBrush
|
||||
{
|
||||
get => (Brush?)GetValue(TargetBrushProperty);
|
||||
set => SetValue(TargetBrushProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>When true the mode label switches to PSG context; adjusts visual emphasis.</summary>
|
||||
public bool IsRunningMode
|
||||
{
|
||||
get => (bool)GetValue(IsRunningModeProperty);
|
||||
set => SetValue(IsRunningModeProperty, value);
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
double side = double.IsInfinity(availableSize.Width) ? 240.0 : availableSize.Width;
|
||||
if (!double.IsInfinity(availableSize.Height))
|
||||
side = Math.Min(side, availableSize.Height);
|
||||
side = Math.Max(side, 120.0);
|
||||
return new Size(side, side);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
double w = ActualWidth;
|
||||
double h = ActualHeight;
|
||||
double side = Math.Min(w, h);
|
||||
double cx = w / 2.0;
|
||||
double cy = h / 2.0;
|
||||
|
||||
// Radii as fractions of half-side
|
||||
double halfS = side / 2.0;
|
||||
double rOuter = halfS * 0.88;
|
||||
double rInner = halfS * 0.82;
|
||||
double rTickOut = halfS * 0.86;
|
||||
double rMinorIn = halfS * 0.80;
|
||||
double rMajorIn = halfS * 0.74;
|
||||
double rLabel = halfS * 0.68;
|
||||
double rThumbTip = halfS * 0.88;
|
||||
double rThumbBase= halfS * 0.72;
|
||||
double thumbHalf = halfS * 0.06; // half-width of triangle base
|
||||
|
||||
// Brushes — use WPF-UI resources when available, safe hard-coded fallbacks otherwise
|
||||
Brush ringBrush = GetThemeBrush("ControlStrokeColorDefaultBrush", new SolidColorBrush(Color.FromArgb(64, 200, 200, 200)));
|
||||
Brush tickBrush = GetThemeBrush("TextFillColorSecondaryBrush", new SolidColorBrush(Color.FromArgb(120, 180, 180, 180)));
|
||||
Brush majorBrush = GetThemeBrush("TextFillColorPrimaryBrush", Brushes.WhiteSmoke);
|
||||
Brush accentBrush = GetThemeBrush("AccentFillColorPrimaryBrush", Brushes.DodgerBlue);
|
||||
Brush primaryFill = PrimaryBrush ?? accentBrush;
|
||||
Brush targetFill = TargetBrush ?? Brushes.White;
|
||||
Brush ghostFill = new SolidColorBrush(Color.FromArgb(80, 200, 200, 200));
|
||||
|
||||
var ringPen = new Pen(ringBrush, 1.0);
|
||||
var minorPen = new Pen(tickBrush, 1.0);
|
||||
var majorPen = new Pen(majorBrush, 2.0);
|
||||
|
||||
// Background ring (outer circle)
|
||||
dc.DrawEllipse(null, ringPen, new Point(cx, cy), rOuter, rOuter);
|
||||
dc.DrawEllipse(null, new Pen(ringBrush, 0.5), new Point(cx, cy), rInner, rInner);
|
||||
|
||||
// Tick marks and labels
|
||||
var labelTypeface = new Typeface("Segoe UI");
|
||||
double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
|
||||
|
||||
for (int deg = 0; deg < 360; deg += 5)
|
||||
{
|
||||
bool isMajor = (deg % 45 == 0);
|
||||
double rad = deg * Math.PI / 180.0;
|
||||
double sinA = Math.Sin(rad);
|
||||
double cosA = Math.Cos(rad);
|
||||
|
||||
double oR = rTickOut;
|
||||
double iR = isMajor ? rMajorIn : rMinorIn;
|
||||
var p1 = new Point(cx + oR * sinA, cy - oR * cosA);
|
||||
var p2 = new Point(cx + iR * sinA, cy - iR * cosA);
|
||||
dc.DrawLine(isMajor ? majorPen : minorPen, p1, p2);
|
||||
|
||||
if (isMajor)
|
||||
{
|
||||
string label = deg.ToString(CultureInfo.InvariantCulture);
|
||||
var ft = new FormattedText(label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||
labelTypeface, 10, majorBrush, dpi);
|
||||
var labelPt = new Point(
|
||||
cx + rLabel * sinA - ft.Width / 2,
|
||||
cy - rLabel * cosA - ft.Height / 2);
|
||||
dc.DrawText(ft, labelPt);
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost / secondary thumb
|
||||
if (SecondaryAngle.HasValue)
|
||||
DrawThumb(dc, cx, cy, SecondaryAngle.Value, rThumbTip, rThumbBase, thumbHalf, ghostFill, null);
|
||||
|
||||
// Target thumb (outline + thin fill)
|
||||
if (TargetAngle.HasValue)
|
||||
{
|
||||
var targetOutline = new Pen(targetFill, 1.5);
|
||||
DrawThumb(dc, cx, cy, TargetAngle.Value, rThumbTip + halfS * 0.03, rThumbBase - halfS * 0.03, thumbHalf * 1.3, null, targetOutline);
|
||||
}
|
||||
|
||||
// Primary thumb (filled)
|
||||
DrawThumb(dc, cx, cy, PrimaryAngle, rThumbTip, rThumbBase, thumbHalf, primaryFill, null);
|
||||
|
||||
// Centre readout
|
||||
string centre = $"{PrimaryAngle:F1}°";
|
||||
var cft = new FormattedText(centre, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||
new Typeface("Consolas"), 14, primaryFill, dpi);
|
||||
dc.DrawText(cft, new Point(cx - cft.Width / 2, cy - cft.Height / 2));
|
||||
|
||||
// Mode label (tiny)
|
||||
string modeLabel = IsRunningMode ? "PSG" : "MAN";
|
||||
var mft = new FormattedText(modeLabel, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||
new Typeface("Segoe UI"), 9, tickBrush, dpi);
|
||||
dc.DrawText(mft, new Point(cx - mft.Width / 2, cy + 12));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Draws an inward-pointing triangle thumb at the given dial angle.</summary>
|
||||
private static void DrawThumb(
|
||||
DrawingContext dc, double cx, double cy,
|
||||
double angleDeg, double tipR, double baseR, double halfWidth,
|
||||
Brush? fill, Pen? outline)
|
||||
{
|
||||
double rad = angleDeg * Math.PI / 180.0;
|
||||
double sinA = Math.Sin(rad);
|
||||
double cosA = Math.Cos(rad);
|
||||
double perpSin = Math.Sin(rad + Math.PI / 2.0);
|
||||
double perpCos = Math.Cos(rad + Math.PI / 2.0);
|
||||
|
||||
var tip = new Point(cx + tipR * sinA, cy - tipR * cosA);
|
||||
var left = new Point(cx + baseR * sinA + halfWidth * perpSin,
|
||||
cy - baseR * cosA - halfWidth * perpCos);
|
||||
var right = new Point(cx + baseR * sinA - halfWidth * perpSin,
|
||||
cy - baseR * cosA + halfWidth * perpCos);
|
||||
|
||||
var geo = new StreamGeometry();
|
||||
using (var ctx = geo.Open())
|
||||
{
|
||||
ctx.BeginFigure(tip, true, true);
|
||||
ctx.LineTo(left, true, false);
|
||||
ctx.LineTo(right, true, false);
|
||||
}
|
||||
geo.Freeze();
|
||||
dc.DrawGeometry(fill, outline, geo);
|
||||
}
|
||||
|
||||
/// <summary>Resolves a WPF-UI theme brush by key with a safe fallback.</summary>
|
||||
private Brush GetThemeBrush(string key, Brush fallback)
|
||||
=> TryFindResource(key) as Brush ?? fallback;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user