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>
229 lines
11 KiB
C#
229 lines
11 KiB
C#
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;
|
|
}
|
|
}
|