using System; using System.Collections.Generic; using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Threading; using LanMontainDesktop.Models; using LanMontainDesktop.Services; using LanMontainDesktop.Theme; namespace LanMontainDesktop.Views.Components; public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget { private const double NormalTextMinContrast = 4.5; private const double LargeTextMinContrast = 3.0; private static readonly Color[] LightToneCandidates = { Color.Parse("#FFFFFFFF"), Color.Parse("#FFF8FCFF"), Color.Parse("#FFF0F7FF"), Color.Parse("#FFE8F3FF"), Color.Parse("#FFE0EEFF") }; private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); private readonly object _snapshotSync = new(); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly DispatcherTimer _renderTimer = new() { Interval = TimeSpan.FromMilliseconds(33) }; private StudyAnalyticsSnapshot? _pendingSnapshot; private bool _hasPendingSnapshot; private double _currentCellSize = 48; private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; private bool _isSubscribed; private int _framesSinceCompaction; private enum StatusVisualKind { Default = 0, Quiet = 1, Noisy = 2, Error = 3 } public StudyNoiseCurveWidget() { InitializeComponent(); _renderTimer.Tick += OnRenderTimerTick; AttachedToVisualTree += OnAttachedToVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree; SizeChanged += OnSizeChanged; ReloadLanguageCode(); ApplyCellSize(_currentCellSize); ApplyDefaultXAxisLabels(); ApplyTypographyByBackground(ResolvePanelBackgroundColor()); ApplyStatusBadgeStyle(StatusVisualKind.Default, ResolvePanelBackgroundColor()); } public void ApplyCellSize(double cellSize) { _currentCellSize = Math.Max(1, cellSize); var scale = Math.Clamp(_currentCellSize / 48d, 0.78, 2.4); RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 14, 42)); RootBorder.Padding = new Thickness( Math.Clamp(14 * scale, 8, 22), Math.Clamp(10 * scale, 6, 16)); StatusTextBlock.FontSize = Math.Clamp(16 * scale, 12, 30); RealtimeValueTextBlock.FontSize = Math.Clamp(18 * scale, 12, 34); StatusBadgeBorder.Padding = new Thickness( Math.Clamp(8 * scale, 4, 11), Math.Clamp(3 * scale, 2, 6)); StatusBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(8 * scale, 5, 12)); StatusBadgeBorder.BorderThickness = new Thickness(Math.Clamp(1 * scale, 0.8, 1.5)); var axisFontSize = Math.Clamp(10 * scale, 9.5, 18); YTopTextBlock.FontSize = axisFontSize; YUpperTextBlock.FontSize = axisFontSize; YMiddleTextBlock.FontSize = axisFontSize; YLowerTextBlock.FontSize = axisFontSize; YBottomTextBlock.FontSize = axisFontSize; XLeftTextBlock.FontSize = axisFontSize; XCenterTextBlock.FontSize = axisFontSize; XRightTextBlock.FontSize = axisFontSize; } public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) { _ = isEditMode; _isOnActivePage = isOnActivePage; UpdateRenderLoopState(); } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = true; ReloadLanguageCode(); if (!_isSubscribed) { _studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated; _isSubscribed = true; } _ = _studyAnalyticsService.StartOrResumeMonitoring(); lock (_snapshotSync) { _pendingSnapshot = _studyAnalyticsService.GetSnapshot(); _hasPendingSnapshot = true; } UpdateRenderLoopState(); } private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = false; _renderTimer.Stop(); if (_isSubscribed) { _studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated; _isSubscribed = false; } } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) { ApplyCellSize(_currentCellSize); var panelColor = ResolvePanelBackgroundColor(); ApplyTypographyByBackground(panelColor); } private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e) { lock (_snapshotSync) { _pendingSnapshot = e.Snapshot; _hasPendingSnapshot = true; } } private void OnRenderTimerTick(object? sender, EventArgs e) { StudyAnalyticsSnapshot? snapshot = null; lock (_snapshotSync) { if (_hasPendingSnapshot) { snapshot = _pendingSnapshot; _hasPendingSnapshot = false; } } if (snapshot is null) { return; } ApplySnapshot(snapshot); _framesSinceCompaction++; if (_framesSinceCompaction >= 900) { ChartControl.CompactCaches(); _framesSinceCompaction = 0; } } private void UpdateRenderLoopState() { if (_isAttached && _isOnActivePage) { if (!_renderTimer.IsEnabled) { _renderTimer.Start(); } return; } _renderTimer.Stop(); } private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { var panelColor = ResolvePanelBackgroundColor(); ApplyTypographyByBackground(panelColor); var statusKind = ResolveStatusVisualKind(snapshot); StatusTextBlock.Text = ResolveStatusText(snapshot); ApplyStatusBadgeStyle(statusKind, panelColor); if (snapshot.LatestRealtimePoint is { } latestPoint) { RealtimeValueTextBlock.Text = string.Format( CultureInfo.InvariantCulture, L("study.noise_curve.value_format", "{0:F1} dB"), latestPoint.DisplayDb); } else { RealtimeValueTextBlock.Text = L("study.environment.value.unavailable", "--"); } ChartControl.UpdateSeries(snapshot.RealtimeBuffer); UpdateXAxisLabels(snapshot); } private void ApplyTypographyByBackground(Color panelColor) { var samples = BuildPanelBackgroundSamples(panelColor); var primaryBrush = CreateAdaptiveLightBrush(samples, LargeTextMinContrast, preferredAlpha: 0xF6); var secondaryBrush = CreateAdaptiveLightBrush(samples, NormalTextMinContrast, preferredAlpha: 0xDF); RealtimeValueTextBlock.Foreground = primaryBrush; YTopTextBlock.Foreground = secondaryBrush; YUpperTextBlock.Foreground = secondaryBrush; YMiddleTextBlock.Foreground = secondaryBrush; YLowerTextBlock.Foreground = secondaryBrush; YBottomTextBlock.Foreground = secondaryBrush; XLeftTextBlock.Foreground = secondaryBrush; XCenterTextBlock.Foreground = secondaryBrush; XRightTextBlock.Foreground = secondaryBrush; } private void ApplyStatusBadgeStyle(StatusVisualKind kind, Color panelColor) { var badgeBaseColor = kind switch { StatusVisualKind.Quiet => Color.Parse("#FF0F6B49"), StatusVisualKind.Noisy => Color.Parse("#FF805018"), StatusVisualKind.Error => Color.Parse("#FF8D2A3A"), _ => Color.Parse("#FF213547") }; var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate)); var badgeAlpha = panelLuminance > 0.58 ? (byte)0xE6 : panelLuminance > 0.46 ? (byte)0xDB : (byte)0xCC; var badgeColor = Color.FromArgb(badgeAlpha, badgeBaseColor.R, badgeBaseColor.G, badgeBaseColor.B); var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate)); StatusBadgeBorder.Background = new SolidColorBrush(badgeColor); StatusBadgeBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF)); StatusTextBlock.Foreground = CreateAdaptiveLightBrush(new[] { badgeComposite }, NormalTextMinContrast, preferredAlpha: 0xFF); } private static StatusVisualKind ResolveStatusVisualKind(StudyAnalyticsSnapshot snapshot) { if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported || snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error) { return StatusVisualKind.Error; } if (snapshot.StreamStatus == NoiseStreamStatus.Noisy) { return StatusVisualKind.Noisy; } if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet) { return StatusVisualKind.Quiet; } return StatusVisualKind.Default; } private Color ResolvePanelBackgroundColor() { if (RootBorder.Background is ISolidColorBrush solidBackground) { return solidBackground.Color; } if (TryGetResource("AdaptiveGlassStrongBackgroundBrush", null, out var resource) && resource is ISolidColorBrush solidBrush) { return solidBrush.Color; } return Color.Parse("#FF1E293B"); } private static IReadOnlyList BuildPanelBackgroundSamples(Color panelColor) { var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate); var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate); return new[] { opaqueOnDark, opaqueOnLight, ColorMath.Blend(opaqueOnDark, DarkSubstrate, 0.28), ColorMath.Blend(opaqueOnDark, Color.Parse("#FFFFFFFF"), 0.16), ColorMath.Blend(opaqueOnLight, Color.Parse("#FFFFFFFF"), 0.08), ColorMath.Blend(opaqueOnLight, DarkSubstrate, 0.18) }; } private static SolidColorBrush CreateAdaptiveLightBrush( IReadOnlyList backgroundSamples, double minContrast, byte preferredAlpha) { var alphaCandidates = new byte[] { preferredAlpha, (byte)Math.Clamp(preferredAlpha + 20, 0, 255), 0xFF }; for (var alphaIndex = 0; alphaIndex < alphaCandidates.Length; alphaIndex++) { var alpha = alphaCandidates[alphaIndex]; for (var toneIndex = 0; toneIndex < LightToneCandidates.Length; toneIndex++) { var tone = LightToneCandidates[toneIndex]; var candidate = Color.FromArgb(alpha, tone.R, tone.G, tone.B); if (MinContrastRatio(candidate, backgroundSamples) >= minContrast) { return new SolidColorBrush(candidate); } } } return new SolidColorBrush(Color.Parse("#FFFFFFFF")); } private static double MinContrastRatio(Color foreground, IReadOnlyList backgrounds) { if (backgrounds.Count == 0) { return 21; } var minimum = double.MaxValue; for (var i = 0; i < backgrounds.Count; i++) { var background = backgrounds[i]; var visibleForeground = foreground.A >= 0xFF ? Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B) : ToOpaqueAgainst(foreground, background); var ratio = ColorMath.ContrastRatio(visibleForeground, background); if (ratio < minimum) { minimum = ratio; } } return minimum; } private static Color ToOpaqueAgainst(Color foreground, Color background) { if (foreground.A >= 0xFF) { return Color.FromArgb(0xFF, foreground.R, foreground.G, foreground.B); } var alpha = foreground.A / 255d; var red = (byte)Math.Round((foreground.R * alpha) + (background.R * (1 - alpha))); var green = (byte)Math.Round((foreground.G * alpha) + (background.G * (1 - alpha))); var blue = (byte)Math.Round((foreground.B * alpha) + (background.B * (1 - alpha))); return Color.FromArgb(0xFF, red, green, blue); } private static double RelativeLuminance(Color color) { static double ToLinear(byte channel) { var c = channel / 255d; return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); } var r = ToLinear(color.R); var g = ToLinear(color.G); var b = ToLinear(color.B); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } private void UpdateXAxisLabels(StudyAnalyticsSnapshot snapshot) { var buffer = snapshot.RealtimeBuffer; if (buffer.Count < 2) { ApplyDefaultXAxisLabels(); return; } var duration = (buffer[^1].Timestamp - buffer[0].Timestamp).TotalSeconds; if (double.IsNaN(duration) || double.IsInfinity(duration) || duration <= 1) { duration = 12; } duration = Math.Clamp(duration, 4, 60); var leftSeconds = Math.Round(duration, MidpointRounding.AwayFromZero); var centerSeconds = Math.Round(duration / 2d, MidpointRounding.AwayFromZero); XLeftTextBlock.Text = $"-{leftSeconds:0}s"; XCenterTextBlock.Text = $"-{centerSeconds:0}s"; XRightTextBlock.Text = L("study.noise_curve.axis.now", "现在"); } private void ApplyDefaultXAxisLabels() { XLeftTextBlock.Text = "-12s"; XCenterTextBlock.Text = "-6s"; XRightTextBlock.Text = L("study.noise_curve.axis.now", "现在"); } private string ResolveStatusText(StudyAnalyticsSnapshot snapshot) { if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported) { return L("study.environment.status.unsupported", "不支持"); } if (snapshot.State == StudyAnalyticsRuntimeState.Error || snapshot.StreamStatus == NoiseStreamStatus.Error) { return L("study.environment.status.error", "错误"); } if (snapshot.State == StudyAnalyticsRuntimeState.Paused) { return L("study.environment.status.paused", "已暂停"); } if (snapshot.StreamStatus == NoiseStreamStatus.Noisy) { return L("study.environment.status.noisy", "嘈杂"); } if (snapshot.State == StudyAnalyticsRuntimeState.Running && snapshot.StreamStatus == NoiseStreamStatus.Quiet) { return L("study.environment.status.quiet", "安静"); } if (snapshot.State == StudyAnalyticsRuntimeState.Ready) { return L("study.environment.status.ready", "待机"); } return L("study.environment.status.initializing", "初始化中"); } private void ReloadLanguageCode() { var snapshot = _settingsService.Load(); _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); } private string L(string key, string fallback) { return _localizationService.GetString(_languageCode, key, fallback); } }