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:
2026-04-20 17:45:59 +02:00
parent 70be693116
commit 69bfda54e1
19 changed files with 1361 additions and 59 deletions

View 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;
}
}