using System; using System.Globalization; using System.Windows; using System.Windows.Media; namespace HC_APTBS.Views.Controls { /// /// 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). /// 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 ──────────────────────────────────────────────────── /// Primary thumb angle in degrees (0 = top, clockwise positive). public double PrimaryAngle { get => (double)GetValue(PrimaryAngleProperty); set => SetValue(PrimaryAngleProperty, value); } /// Target thumb angle in degrees, or null to hide it. public double? TargetAngle { get => (double?)GetValue(TargetAngleProperty); set => SetValue(TargetAngleProperty, value); } /// Ghost secondary thumb angle in degrees, or null to hide it. public double? SecondaryAngle { get => (double?)GetValue(SecondaryAngleProperty); set => SetValue(SecondaryAngleProperty, value); } /// Fill brush for the primary thumb. Falls back to the accent brush when null. public Brush? PrimaryBrush { get => (Brush?)GetValue(PrimaryBrushProperty); set => SetValue(PrimaryBrushProperty, value); } /// Fill brush for the target thumb (green = within tolerance, red = off-target). public Brush? TargetBrush { get => (Brush?)GetValue(TargetBrushProperty); set => SetValue(TargetBrushProperty, value); } /// When true the mode label switches to PSG context; adjusts visual emphasis. 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 ─────────────────────────────────────────────────────────────── /// Draws an inward-pointing triangle thumb at the given dial angle. 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); } /// Resolves a WPF-UI theme brush by key with a safe fallback. private Brush GetThemeBrush(string key, Brush fallback) => TryFindResource(key) as Brush ?? fallback; } }