From 40ddcd399d36a3f71d6ad9d40005a84b9157dfaa Mon Sep 17 00:00:00 2001 From: lincube Date: Wed, 4 Mar 2026 20:03:14 +0800 Subject: [PATCH] 0.3.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加了自习系列组件 --- .../ComponentSystem/BuiltInComponentIds.cs | 3 + .../ComponentSystem/ComponentRegistry.cs | 30 + LanMountainDesktop/Localization/en-US.json | 56 ++ LanMountainDesktop/Localization/zh-CN.json | 56 ++ .../Services/IAudioRecorderService.cs | 26 +- ...tudyAnalyticsMonitoringLeaseCoordinator.cs | 94 +++ .../Services/StudyAnalyticsService.cs | 2 +- .../DesktopComponentRuntimeRegistry.cs | 15 + .../Views/Components/RecordingWidget.axaml.cs | 2 +- .../StudyDeductionReasonsWidget.axaml | 152 ++++ .../StudyDeductionReasonsWidget.axaml.cs | 628 +++++++++++++++++ .../StudyEnvironmentWidget.axaml.cs | 20 +- .../StudyInterruptDensityWidget.axaml | 136 ++++ .../StudyInterruptDensityWidget.axaml.cs | 649 ++++++++++++++++++ .../Components/StudyNoiseCurveWidget.axaml.cs | 20 +- ...udyNoiseDistributionScatterChartControl.cs | 180 +++++ .../StudyNoiseDistributionWidget.axaml | 110 +++ .../StudyNoiseDistributionWidget.axaml.cs | 602 ++++++++++++++++ .../Components/StudyScoreOverviewWidget.axaml | 124 ++-- .../StudyScoreOverviewWidget.axaml.cs | 106 ++- 20 files changed, 2932 insertions(+), 79 deletions(-) create mode 100644 LanMountainDesktop/Services/StudyAnalyticsMonitoringLeaseCoordinator.cs create mode 100644 LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs create mode 100644 LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs create mode 100644 LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs create mode 100644 LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 60bb690..7c8ff75 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -15,7 +15,10 @@ public static class BuiltInComponentIds public const string DesktopAudioRecorder = "DesktopAudioRecorder"; public const string DesktopStudyEnvironment = "DesktopStudyEnvironment"; public const string DesktopStudyNoiseCurve = "DesktopStudyNoiseCurve"; + public const string DesktopStudyNoiseDistribution = "DesktopStudyNoiseDistribution"; public const string DesktopStudyScoreOverview = "DesktopStudyScoreOverview"; + public const string DesktopStudyDeductionReasons = "DesktopStudyDeductionReasons"; + public const string DesktopStudyInterruptDensity = "DesktopStudyInterruptDensity"; public const string Blank2x4 = "Blank2x4"; public const string Date = "Date"; public const string MonthCalendar = "MonthCalendar"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 48c249c..37bbbbf 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -140,6 +140,16 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudyNoiseDistribution, + "Noise Distribution", + "DataLine", + "Study", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), new DesktopComponentDefinition( BuiltInComponentIds.DesktopStudyScoreOverview, "Study Score Overview", @@ -149,6 +159,26 @@ public sealed class ComponentRegistry MinHeightCells: 4, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudyDeductionReasons, + "Deduction Reasons", + "DataLine", + "Study", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopStudyInterruptDensity, + "Interrupt Density", + "DataLine", + "Study", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), new DesktopComponentDefinition( BuiltInComponentIds.DesktopDailyPoetry, "Daily Poetry", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 94edb80..e074b71 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -235,7 +235,10 @@ "component.holiday_calendar": "Holiday Calendar", "component.study_environment": "Environment", "component.study_noise_curve": "Noise Curve", + "component.study_noise_distribution": "Noise Distribution", "component.study_score_overview": "Study Score Overview", + "component.study_deduction_reasons": "Deduction Reasons", + "component.study_interrupt_density": "Interrupt Density", "poetry.widget.loading_content": "Loading poetry...", "poetry.widget.loading_author": "Loading...", "poetry.widget.fetch_failed": "Poetry fetch failed", @@ -289,6 +292,21 @@ "study.environment.settings.hint": "At least one display mode must stay enabled.", "study.noise_curve.value_format": "{0:F1} dB", "study.noise_curve.axis.now": "Now", + "study.noise_distribution.title": "Noise Level Distribution", + "study.noise_distribution.mode.realtime": "Realtime", + "study.noise_distribution.mode.session": "Session", + "study.noise_distribution.summary.mainly_format": "Mainly: {0}", + "study.noise_distribution.summary.latest_format": "Latest: {0}", + "study.noise_distribution.summary.compact_format": "Main {0} · New {1}", + "study.noise_distribution.level.quiet": "Quiet", + "study.noise_distribution.level.normal": "Normal", + "study.noise_distribution.level.noisy": "Noisy", + "study.noise_distribution.level.extreme": "Extreme", + "study.noise_distribution.axis.extreme": "Extreme", + "study.noise_distribution.axis.noisy": "Noisy", + "study.noise_distribution.axis.normal": "Normal", + "study.noise_distribution.axis.quiet": "Quiet", + "study.noise_distribution.axis.now": "Now", "study.score_overview.title": "Study Score", "study.score_overview.mode.realtime": "Realtime", "study.score_overview.mode.session": "Session", @@ -300,6 +318,44 @@ "study.score_overview.minimum_short": "Min", "study.score_overview.maximum_short": "Max", "study.score_overview.unavailable": "--", + "study.deduction.title": "Deduction Reasons", + "study.deduction.mode.realtime": "Realtime", + "study.deduction.mode.session": "Session", + "study.deduction.reason.sustained": "Sustained Noise", + "study.deduction.reason.time": "Over-threshold Time", + "study.deduction.reason.segment": "Interrupt Frequency", + "study.deduction.reason.sustained_short": "Sustained", + "study.deduction.reason.time_short": "Duration", + "study.deduction.reason.segment_short": "Interrupt", + "study.deduction.metric.sustained_format": "p50 {0:F1} dBFS", + "study.deduction.metric.sustained_short_format": "p50 {0:F1}", + "study.deduction.metric.time_format": "over {0:F1}%", + "study.deduction.metric.time_short_format": "{0:F1}%", + "study.deduction.metric.segment_format": "{0:F1}/min", + "study.deduction.metric.segment_short_format": "{0:F1}/m", + "study.deduction.loss_format": "-{0:F1}", + "study.deduction.total_loss_format": "Total -{0:F1}", + "study.deduction.total_score_format": "Score {0:F1}", + "study.deduction.total_loss_unavailable": "Total {0}", + "study.deduction.total_score_unavailable": "Score {0}", + "study.deduction.unavailable": "--", + "study.interrupt_density.title": "Interrupt Density", + "study.interrupt_density.mode.realtime": "Realtime", + "study.interrupt_density.mode.session": "Session", + "study.interrupt_density.unit": "/min", + "study.interrupt_density.segment_count": "Interrupts", + "study.interrupt_density.segment_count_short": "Count", + "study.interrupt_density.duration": "Duration", + "study.interrupt_density.duration_short": "Time", + "study.interrupt_density.density_value_format": "{0:F1}", + "study.interrupt_density.segment_count_value_format": "{0}", + "study.interrupt_density.level_format": "Level {0}", + "study.interrupt_density.level.calm": "Calm", + "study.interrupt_density.level.normal": "Normal", + "study.interrupt_density.level.frequent": "Frequent", + "study.interrupt_density.level.severe": "Severe", + "study.interrupt_density.threshold_format": "Penalty threshold {0:F1}/min", + "study.interrupt_density.unavailable": "--", "desktop.add_page": "Add page", "desktop.delete_page": "Delete page", "placement.fill": "Fill", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 0c33927..30eaa21 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -235,7 +235,10 @@ "component.holiday_calendar": "节假日日历", "component.study_environment": "环境", "component.study_noise_curve": "噪音曲线", + "component.study_noise_distribution": "噪音等级分布", "component.study_score_overview": "自习评分总览", + "component.study_deduction_reasons": "扣分原因", + "component.study_interrupt_density": "打断密度", "poetry.widget.loading_content": "正在加载诗词", "poetry.widget.loading_author": "加载中", "poetry.widget.fetch_failed": "诗词获取失败", @@ -289,6 +292,21 @@ "study.environment.settings.hint": "至少启用一种显示方式。", "study.noise_curve.value_format": "{0:F1} dB", "study.noise_curve.axis.now": "现在", + "study.noise_distribution.title": "噪音等级分布", + "study.noise_distribution.mode.realtime": "实时", + "study.noise_distribution.mode.session": "时段", + "study.noise_distribution.summary.mainly_format": "主要:{0}", + "study.noise_distribution.summary.latest_format": "最新:{0}", + "study.noise_distribution.summary.compact_format": "主 {0} · 新 {1}", + "study.noise_distribution.level.quiet": "安静", + "study.noise_distribution.level.normal": "正常", + "study.noise_distribution.level.noisy": "吵闹", + "study.noise_distribution.level.extreme": "极吵", + "study.noise_distribution.axis.extreme": "极吵", + "study.noise_distribution.axis.noisy": "吵闹", + "study.noise_distribution.axis.normal": "正常", + "study.noise_distribution.axis.quiet": "安静", + "study.noise_distribution.axis.now": "现在", "study.score_overview.title": "自习评分", "study.score_overview.mode.realtime": "实时", "study.score_overview.mode.session": "时段", @@ -300,6 +318,44 @@ "study.score_overview.minimum_short": "低", "study.score_overview.maximum_short": "高", "study.score_overview.unavailable": "--", + "study.deduction.title": "扣分原因", + "study.deduction.mode.realtime": "实时", + "study.deduction.mode.session": "时段", + "study.deduction.reason.sustained": "持续噪音", + "study.deduction.reason.time": "超阈时长", + "study.deduction.reason.segment": "打断频次", + "study.deduction.reason.sustained_short": "持续", + "study.deduction.reason.time_short": "时长", + "study.deduction.reason.segment_short": "打断", + "study.deduction.metric.sustained_format": "p50 {0:F1} dBFS", + "study.deduction.metric.sustained_short_format": "p50 {0:F1}", + "study.deduction.metric.time_format": "超阈 {0:F1}%", + "study.deduction.metric.time_short_format": "{0:F1}%", + "study.deduction.metric.segment_format": "{0:F1} 次/分钟", + "study.deduction.metric.segment_short_format": "{0:F1}/分", + "study.deduction.loss_format": "-{0:F1}", + "study.deduction.total_loss_format": "总扣分 -{0:F1}", + "study.deduction.total_score_format": "评分 {0:F1}", + "study.deduction.total_loss_unavailable": "总扣分 {0}", + "study.deduction.total_score_unavailable": "评分 {0}", + "study.deduction.unavailable": "--", + "study.interrupt_density.title": "打断密度", + "study.interrupt_density.mode.realtime": "实时", + "study.interrupt_density.mode.session": "时段", + "study.interrupt_density.unit": "次/分钟", + "study.interrupt_density.segment_count": "打断次数", + "study.interrupt_density.segment_count_short": "次数", + "study.interrupt_density.duration": "统计时长", + "study.interrupt_density.duration_short": "时长", + "study.interrupt_density.density_value_format": "{0:F1}", + "study.interrupt_density.segment_count_value_format": "{0}", + "study.interrupt_density.level_format": "打断等级:{0}", + "study.interrupt_density.level.calm": "低", + "study.interrupt_density.level.normal": "中", + "study.interrupt_density.level.frequent": "高", + "study.interrupt_density.level.severe": "极高", + "study.interrupt_density.threshold_format": "满扣阈值 {0:F1} 次/分钟", + "study.interrupt_density.unavailable": "--", "desktop.add_page": "新增页面", "desktop.delete_page": "删除页面", "placement.fill": "填充", diff --git a/LanMountainDesktop/Services/IAudioRecorderService.cs b/LanMountainDesktop/Services/IAudioRecorderService.cs index 1f0b3c9..86de96c 100644 --- a/LanMountainDesktop/Services/IAudioRecorderService.cs +++ b/LanMountainDesktop/Services/IAudioRecorderService.cs @@ -42,7 +42,7 @@ public interface IAudioRecorderService : IDisposable public static class AudioRecorderServiceFactory { - private static readonly Lazy SharedService = new( + private static readonly Lazy SharedRecorderService = new( () => { if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) @@ -54,9 +54,31 @@ public static class AudioRecorderServiceFactory }, isThreadSafe: true); + private static readonly Lazy SharedStudyMonitoringService = new( + () => + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return new NoOpAudioRecorderService("Unsupported platform"); + } + + return new PortAudioRecorderService(); + }, + isThreadSafe: true); + + public static IAudioRecorderService CreateRecorder() + { + return SharedRecorderService.Value; + } + + public static IAudioRecorderService CreateStudyMonitoring() + { + return SharedStudyMonitoringService.Value; + } + public static IAudioRecorderService CreateDefault() { - return SharedService.Value; + return CreateRecorder(); } } diff --git a/LanMountainDesktop/Services/StudyAnalyticsMonitoringLeaseCoordinator.cs b/LanMountainDesktop/Services/StudyAnalyticsMonitoringLeaseCoordinator.cs new file mode 100644 index 0000000..abc3637 --- /dev/null +++ b/LanMountainDesktop/Services/StudyAnalyticsMonitoringLeaseCoordinator.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +public static class StudyAnalyticsMonitoringLeaseCoordinatorFactory +{ + private static readonly Lazy SharedCoordinator = new( + () => new StudyAnalyticsMonitoringLeaseCoordinator(), + isThreadSafe: true); + + public static StudyAnalyticsMonitoringLeaseCoordinator CreateDefault() + { + return SharedCoordinator.Value; + } +} + +public sealed class StudyAnalyticsMonitoringLeaseCoordinator +{ + private readonly object _syncRoot = new(); + private readonly IStudyAnalyticsService _studyAnalyticsService; + private int _activeLeaseCount; + + public StudyAnalyticsMonitoringLeaseCoordinator(IStudyAnalyticsService? studyAnalyticsService = null) + { + _studyAnalyticsService = studyAnalyticsService ?? StudyAnalyticsServiceFactory.CreateDefault(); + } + + public IDisposable AcquireLease() + { + var shouldStartMonitoring = false; + lock (_syncRoot) + { + _activeLeaseCount++; + if (_activeLeaseCount == 1) + { + shouldStartMonitoring = true; + } + } + + if (shouldStartMonitoring) + { + _ = _studyAnalyticsService.StartOrResumeMonitoring(); + } + + return new MonitoringLease(this); + } + + private void ReleaseLease() + { + var shouldPauseMonitoring = false; + lock (_syncRoot) + { + if (_activeLeaseCount <= 0) + { + return; + } + + _activeLeaseCount--; + if (_activeLeaseCount == 0) + { + shouldPauseMonitoring = true; + } + } + + if (!shouldPauseMonitoring) + { + return; + } + + var snapshot = _studyAnalyticsService.GetSnapshot(); + if (snapshot.Session.State != StudySessionRuntimeState.Running) + { + _ = _studyAnalyticsService.PauseMonitoring(); + } + } + + private sealed class MonitoringLease : IDisposable + { + private StudyAnalyticsMonitoringLeaseCoordinator? _owner; + + public MonitoringLease(StudyAnalyticsMonitoringLeaseCoordinator owner) + { + _owner = owner; + } + + public void Dispose() + { + var owner = Interlocked.Exchange(ref _owner, null); + owner?.ReleaseLease(); + } + } +} diff --git a/LanMountainDesktop/Services/StudyAnalyticsService.cs b/LanMountainDesktop/Services/StudyAnalyticsService.cs index 94ad61a..9572614 100644 --- a/LanMountainDesktop/Services/StudyAnalyticsService.cs +++ b/LanMountainDesktop/Services/StudyAnalyticsService.cs @@ -36,7 +36,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null) { - _audioRecorderService = audioRecorderService ?? AudioRecorderServiceFactory.CreateDefault(); + _audioRecorderService = audioRecorderService ?? AudioRecorderServiceFactory.CreateStudyMonitoring(); _pipeline = new NoiseFramePipeline(_config); _samplingTimer = new Timer(OnSamplingTick, null, Timeout.Infinite, Timeout.Infinite); diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index d46a6e8..2a50cb4 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -184,11 +184,26 @@ public sealed class DesktopComponentRuntimeRegistry "component.study_noise_curve", () => new StudyNoiseCurveWidget(), cellSize => Math.Clamp(cellSize * 0.34, 12, 26)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudyNoiseDistribution, + "component.study_noise_distribution", + () => new StudyNoiseDistributionWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 12, 26)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopStudyScoreOverview, "component.study_score_overview", () => new StudyScoreOverviewWidget(), cellSize => Math.Clamp(cellSize * 0.34, 12, 28)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudyDeductionReasons, + "component.study_deduction_reasons", + () => new StudyDeductionReasonsWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 10, 24)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopStudyInterruptDensity, + "component.study_interrupt_density", + () => new StudyInterruptDensityWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 10, 24)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopDailyPoetry, "component.daily_poetry", diff --git a/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs b/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs index 44d0359..309cffe 100644 --- a/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/RecordingWidget.axaml.cs @@ -22,7 +22,7 @@ public partial class RecordingWidget : UserControl, IDesktopComponentWidget Interval = TimeSpan.FromMilliseconds(96) }; - private readonly IAudioRecorderService _audioRecorderService = AudioRecorderServiceFactory.CreateDefault(); + private readonly IAudioRecorderService _audioRecorderService = AudioRecorderServiceFactory.CreateRecorder(); private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly List _waveBars = []; diff --git a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml new file mode 100644 index 0000000..4ee2cef --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs new file mode 100644 index 0000000..084c644 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyDeductionReasonsWidget.axaml.cs @@ -0,0 +1,628 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; + +namespace LanMountainDesktop.Views.Components; + +public partial class StudyDeductionReasonsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +{ + private static readonly Color[] PrimaryColorCandidates = + { + Color.Parse("#FFEAF5FF"), + Color.Parse("#FFDDEEFF"), + Color.Parse("#FFCEE3FA"), + Color.Parse("#FF1B2E45"), + Color.Parse("#FF233A54"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF101C2A") + }; + + private static readonly Color[] SecondaryColorCandidates = + { + Color.Parse("#FFC7D9EC"), + Color.Parse("#FFBAD0E8"), + Color.Parse("#FFD9E8F6"), + Color.Parse("#FF2F4763"), + Color.Parse("#FF385673"), + Color.Parse("#FFEAF3FA"), + Color.Parse("#FF1A2C40") + }; + + private static readonly FontFamily MiSansVariableFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); + private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); + + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly DispatcherTimer _uiTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + + private double _currentCellSize = 48; + private bool _isAttached; + private bool _isOnActivePage = true; + private bool _isCompactMode; + private bool _isUltraCompactMode; + private string _languageCode = "zh-CN"; + + private readonly record struct DeductionMetrics( + double SustainedPenalty, + double TimePenalty, + double SegmentPenalty, + double TotalPenalty, + double Score, + double P50Dbfs, + double OverRatio, + double SegmentsPerMin); + + public StudyDeductionReasonsWidget() + { + InitializeComponent(); + + _uiTimer.Tick += OnUiTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyVariableFontFamily(); + ReloadLanguageCode(); + ApplyCellSize(_currentCellSize); + RefreshVisual(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _ = isEditMode; + _isOnActivePage = isOnActivePage; + UpdateTimerState(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ReloadLanguageCode(); + _ = _studyAnalyticsService.StartOrResumeMonitoring(); + UpdateTimerState(); + RefreshVisual(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _uiTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateAdaptiveLayout(); + ApplyTypographyByBackground(ResolvePanelBackgroundColor()); + } + + private void OnUiTimerTick(object? sender, EventArgs e) + { + RefreshVisual(); + } + + private void UpdateTimerState() + { + if (_isAttached && _isOnActivePage) + { + if (!_uiTimer.IsEnabled) + { + _uiTimer.Start(); + } + + return; + } + + _uiTimer.Stop(); + } + + private void RefreshVisual() + { + var snapshot = _studyAnalyticsService.GetSnapshot(); + var panelColor = ResolvePanelBackgroundColor(); + ApplyTypographyByBackground(panelColor); + + var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; + ModeTextBlock.Text = isSessionRunning + ? L("study.deduction.mode.session", "Session") + : L("study.deduction.mode.realtime", "Realtime"); + ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); + + ApplyLocalizedLabels(); + + var metrics = ComputeRealtimeDeduction(snapshot); + if (metrics is null) + { + ApplyUnavailableMetrics(); + return; + } + + var m = metrics.Value; + var sustainedLoss = 100d * 0.40d * m.SustainedPenalty; + var timeLoss = 100d * 0.30d * m.TimePenalty; + var segmentLoss = 100d * 0.30d * m.SegmentPenalty; + var totalLoss = Math.Max(0, 100d * m.TotalPenalty); + + SustainedMetricTextBlock.Text = _isUltraCompactMode + ? string.Format(CultureInfo.InvariantCulture, L("study.deduction.metric.sustained_short_format", "p50 {0:F1}"), m.P50Dbfs) + : string.Format(CultureInfo.InvariantCulture, L("study.deduction.metric.sustained_format", "p50 {0:F1} dBFS"), m.P50Dbfs); + TimeMetricTextBlock.Text = _isUltraCompactMode + ? string.Format(CultureInfo.InvariantCulture, L("study.deduction.metric.time_short_format", "{0:F1}%"), m.OverRatio * 100d) + : string.Format(CultureInfo.InvariantCulture, L("study.deduction.metric.time_format", "over {0:F1}%"), m.OverRatio * 100d); + SegmentMetricTextBlock.Text = _isUltraCompactMode + ? string.Format(CultureInfo.InvariantCulture, L("study.deduction.metric.segment_short_format", "{0:F1}/m"), m.SegmentsPerMin) + : string.Format(CultureInfo.InvariantCulture, L("study.deduction.metric.segment_format", "{0:F1}/min"), m.SegmentsPerMin); + + SustainedLossTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.loss_format", "-{0:F1}"), sustainedLoss); + TimeLossTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.loss_format", "-{0:F1}"), timeLoss); + SegmentLossTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.loss_format", "-{0:F1}"), segmentLoss); + + TotalLossTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.total_loss_format", "Total -{0:F1}"), totalLoss); + ScoreTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.total_score_format", "Score {0:F1}"), m.Score); + } + + private void ApplyUnavailableMetrics() + { + var unavailable = L("study.deduction.unavailable", "--"); + SustainedMetricTextBlock.Text = unavailable; + TimeMetricTextBlock.Text = unavailable; + SegmentMetricTextBlock.Text = unavailable; + SustainedLossTextBlock.Text = unavailable; + TimeLossTextBlock.Text = unavailable; + SegmentLossTextBlock.Text = unavailable; + TotalLossTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.total_loss_unavailable", "Total {0}"), unavailable); + ScoreTextBlock.Text = string.Format(CultureInfo.InvariantCulture, L("study.deduction.total_score_unavailable", "Score {0}"), unavailable); + } + + private void ApplyLocalizedLabels() + { + TitleTextBlock.Text = L("study.deduction.title", "Deduction Reasons"); + + SustainedReasonTextBlock.Text = _isCompactMode + ? L("study.deduction.reason.sustained_short", "Sustained") + : L("study.deduction.reason.sustained", "Sustained Noise"); + TimeReasonTextBlock.Text = _isCompactMode + ? L("study.deduction.reason.time_short", "Duration") + : L("study.deduction.reason.time", "Over-threshold Time"); + SegmentReasonTextBlock.Text = _isCompactMode + ? L("study.deduction.reason.segment_short", "Interrupt") + : L("study.deduction.reason.segment", "Interrupt Frequency"); + } + + private void UpdateAdaptiveLayout() + { + var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.4); + var widthScale = Bounds.Width > 1 ? Bounds.Width / 420d : cellScale; + var heightScale = Bounds.Height > 1 ? Bounds.Height / 220d : cellScale; + var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.52, 2.2); + var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.06), 0.52, 2.2); + + _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 360) || (Bounds.Height > 1 && Bounds.Height < 180); + _isUltraCompactMode = scale < 0.72 || (Bounds.Width > 1 && Bounds.Width < 300) || (Bounds.Height > 1 && Bounds.Height < 145); + + var compactMultiplier = _isUltraCompactMode ? 0.76 : _isCompactMode ? 0.88 : 1.0; + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.46, 12, 34)); + RootBorder.Padding = new Thickness( + Math.Clamp(12 * scale * compactMultiplier, 6, 18), + Math.Clamp(10 * scale * compactMultiplier, 5, 16)); + + ContentRootGrid.RowSpacing = _isUltraCompactMode + ? Math.Clamp(4 * scale, 2, 6) + : _isCompactMode + ? Math.Clamp(6 * scale, 3, 7) + : Math.Clamp(8 * scale, 4, 10); + HeaderGrid.ColumnSpacing = _isUltraCompactMode + ? Math.Clamp(6 * scale, 3, 8) + : Math.Clamp(8 * scale, 4, 10); + ReasonsListPanel.Spacing = _isUltraCompactMode + ? Math.Clamp(3 * scale, 1, 5) + : _isCompactMode + ? Math.Clamp(4 * scale, 2, 6) + : Math.Clamp(6 * scale, 3, 8); + + TitleTextBlock.FontSize = Math.Clamp(13 * scale, 9, 20); + ModeTextBlock.FontSize = Math.Clamp(11 * scale, 8, 16); + SustainedReasonTextBlock.FontSize = Math.Clamp(13 * scale, 9, 18); + TimeReasonTextBlock.FontSize = Math.Clamp(13 * scale, 9, 18); + SegmentReasonTextBlock.FontSize = Math.Clamp(13 * scale, 9, 18); + + SustainedMetricTextBlock.FontSize = Math.Clamp(11 * scale, 8, 14); + TimeMetricTextBlock.FontSize = Math.Clamp(11 * scale, 8, 14); + SegmentMetricTextBlock.FontSize = Math.Clamp(11 * scale, 8, 14); + + SustainedLossTextBlock.FontSize = Math.Clamp(19 * scale, 11, 28); + TimeLossTextBlock.FontSize = Math.Clamp(19 * scale, 11, 28); + SegmentLossTextBlock.FontSize = Math.Clamp(19 * scale, 11, 28); + + TotalLossTextBlock.FontSize = Math.Clamp(12 * scale, 8, 16); + ScoreTextBlock.FontSize = Math.Clamp(12 * scale, 8, 16); + + ModeBadgeBorder.Padding = new Thickness( + Math.Clamp(8 * scale * compactMultiplier, 4, 12), + Math.Clamp(3 * scale * compactMultiplier, 1.5, 6)); + ModeBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(8 * scale, 4, 12)); + + var rowPadding = new Thickness( + Math.Clamp(10 * scale * compactMultiplier, 5, 14), + Math.Clamp(7 * scale * compactMultiplier, 3, 10)); + SustainedRowBorder.Padding = rowPadding; + TimeRowBorder.Padding = rowPadding; + SegmentRowBorder.Padding = rowPadding; + + SustainedMetricTextBlock.IsVisible = !_isUltraCompactMode; + TimeMetricTextBlock.IsVisible = !_isUltraCompactMode; + SegmentMetricTextBlock.IsVisible = !_isUltraCompactMode; + TitleTextBlock.IsVisible = !_isUltraCompactMode; + + ApplyVariableWeights(scale); + ApplyLocalizedLabels(); + } + + private void ApplyTypographyByBackground(Color panelColor) + { + var samples = BuildPanelBackgroundSamples(panelColor); + var primary = CreateAdaptiveBrush(samples, PrimaryColorCandidates, minContrast: 4.5); + var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5); + + TitleTextBlock.Foreground = secondary; + SustainedMetricTextBlock.Foreground = secondary; + TimeMetricTextBlock.Foreground = secondary; + SegmentMetricTextBlock.Foreground = secondary; + TotalLossTextBlock.Foreground = secondary; + + SustainedReasonTextBlock.Foreground = primary; + TimeReasonTextBlock.Foreground = primary; + SegmentReasonTextBlock.Foreground = primary; + SustainedLossTextBlock.Foreground = primary; + TimeLossTextBlock.Foreground = primary; + SegmentLossTextBlock.Foreground = primary; + ScoreTextBlock.Foreground = primary; + } + + private void ApplyModeBadgeColor(Color panelColor, Color baseColor) + { + var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate)); + var badgeAlpha = panelLuminance > 0.58 + ? (byte)0xE2 + : panelLuminance > 0.46 + ? (byte)0xD8 + : (byte)0xC8; + + var badgeColor = Color.FromArgb(badgeAlpha, baseColor.R, baseColor.G, baseColor.B); + var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate)); + + ModeBadgeBorder.Background = new SolidColorBrush(badgeColor); + ModeBadgeBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF)); + ModeTextBlock.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, PrimaryColorCandidates, minContrast: 4.5); + } + + private static DeductionMetrics? ComputeRealtimeDeduction(StudyAnalyticsSnapshot snapshot) + { + var points = snapshot.RealtimeBuffer; + if (points.Count < 2) + { + return null; + } + + var start = points[0].Timestamp; + var end = points[^1].Timestamp; + var totalDurationMs = (end - start).TotalMilliseconds; + if (totalDurationMs <= Math.Max(300, snapshot.Config.FrameMs * 3)) + { + return null; + } + + var dbfsValues = points.Select(p => p.Dbfs).OrderBy(v => v).ToArray(); + var p50Dbfs = Percentile(dbfsValues, 0.50); + + var overDurationMs = 0d; + var weightedDurationMs = 0d; + var segmentCount = 0; + var segmentOpen = false; + DateTimeOffset? lastOverThresholdAt = null; + + for (var i = 0; i < points.Count - 1; i++) + { + var current = points[i]; + var next = points[i + 1]; + var dtMs = (next.Timestamp - current.Timestamp).TotalMilliseconds; + if (dtMs <= 0) + { + continue; + } + + weightedDurationMs += dtMs; + + if (current.IsOverThreshold) + { + overDurationMs += dtMs; + if (segmentOpen) + { + lastOverThresholdAt = current.Timestamp; + } + else + { + var canMerge = lastOverThresholdAt.HasValue && + (current.Timestamp - lastOverThresholdAt.Value).TotalMilliseconds <= snapshot.Config.SegmentMergeGapMs; + if (!canMerge) + { + segmentCount++; + } + + segmentOpen = true; + lastOverThresholdAt = current.Timestamp; + } + } + else if (segmentOpen && lastOverThresholdAt.HasValue) + { + var silentGapMs = (current.Timestamp - lastOverThresholdAt.Value).TotalMilliseconds; + if (silentGapMs > snapshot.Config.SegmentMergeGapMs) + { + segmentOpen = false; + } + } + } + + if (weightedDurationMs <= 0) + { + weightedDurationMs = points.Count * snapshot.Config.FrameMs; + } + + var overRatio = Math.Clamp(overDurationMs / Math.Max(1, weightedDurationMs), 0, 1); + var minutes = Math.Max(1d / 60d, weightedDurationMs / 60000d); + var segmentsPerMin = segmentCount / minutes; + + var sustainedPenalty = Clamp01((p50Dbfs - snapshot.Config.ScoreThresholdDbfs) / 6d); + var timePenalty = Clamp01(overRatio / 0.30d); + var segmentPenalty = Clamp01(segmentsPerMin / Math.Max(1, snapshot.Config.MaxSegmentsPerMin)); + var totalPenalty = (0.40d * sustainedPenalty) + (0.30d * timePenalty) + (0.30d * segmentPenalty); + var score = Math.Clamp(100d * (1d - totalPenalty), 0, 100); + + return new DeductionMetrics( + SustainedPenalty: Math.Round(sustainedPenalty, 4), + TimePenalty: Math.Round(timePenalty, 4), + SegmentPenalty: Math.Round(segmentPenalty, 4), + TotalPenalty: Math.Round(totalPenalty, 4), + Score: Math.Round(score, 1), + P50Dbfs: Math.Round(p50Dbfs, 2), + OverRatio: Math.Round(overRatio, 4), + SegmentsPerMin: Math.Round(segmentsPerMin, 3)); + } + + private static double Percentile(double[] sortedValues, double percentile) + { + if (sortedValues.Length == 0) + { + return -100; + } + + if (sortedValues.Length == 1) + { + return sortedValues[0]; + } + + var clamped = Math.Clamp(percentile, 0, 1); + var position = (sortedValues.Length - 1) * clamped; + var lower = (int)Math.Floor(position); + var upper = (int)Math.Ceiling(position); + if (lower == upper) + { + return sortedValues[lower]; + } + + var factor = position - lower; + return sortedValues[lower] + ((sortedValues[upper] - sortedValues[lower]) * factor); + } + + private static double Clamp01(double value) + { + return Math.Clamp(value, 0, 1); + } + + private static IReadOnlyList BuildPanelBackgroundSamples(Color panelColor) + { + var opaqueOnDark = ToOpaqueAgainst(panelColor, DarkSubstrate); + var opaqueOnLight = ToOpaqueAgainst(panelColor, LightSubstrate); + + return + [ + 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 CreateAdaptiveBrush( + IReadOnlyList backgroundSamples, + IReadOnlyList colorCandidates, + double minContrast) + { + if (colorCandidates.Count == 0) + { + return new SolidColorBrush(Color.Parse("#FFFFFFFF")); + } + + for (var i = 0; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + if (MinContrastRatio(candidate, backgroundSamples) >= minContrast) + { + return new SolidColorBrush(candidate); + } + } + + var best = colorCandidates[0]; + var bestContrast = MinContrastRatio(best, backgroundSamples); + for (var i = 1; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + var contrast = MinContrastRatio(candidate, backgroundSamples); + if (contrast > bestContrast) + { + best = candidate; + bestContrast = contrast; + } + } + + return new SolidColorBrush(best); + } + + 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 Color ResolvePanelBackgroundColor() + { + if (RootBorder.Background is ISolidColorBrush solidBackground) + { + return solidBackground.Color; + } + + if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) && + resource is ISolidColorBrush solidBrush) + { + return solidBrush.Color; + } + + return Color.Parse("#FF1E293B"); + } + + private void ReloadLanguageCode() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + + private void ApplyVariableFontFamily() + { + TitleTextBlock.FontFamily = MiSansVariableFontFamily; + ModeTextBlock.FontFamily = MiSansVariableFontFamily; + + SustainedReasonTextBlock.FontFamily = MiSansVariableFontFamily; + SustainedMetricTextBlock.FontFamily = MiSansVariableFontFamily; + SustainedLossTextBlock.FontFamily = MiSansVariableFontFamily; + + TimeReasonTextBlock.FontFamily = MiSansVariableFontFamily; + TimeMetricTextBlock.FontFamily = MiSansVariableFontFamily; + TimeLossTextBlock.FontFamily = MiSansVariableFontFamily; + + SegmentReasonTextBlock.FontFamily = MiSansVariableFontFamily; + SegmentMetricTextBlock.FontFamily = MiSansVariableFontFamily; + SegmentLossTextBlock.FontFamily = MiSansVariableFontFamily; + + TotalLossTextBlock.FontFamily = MiSansVariableFontFamily; + ScoreTextBlock.FontFamily = MiSansVariableFontFamily; + } + + private void ApplyVariableWeights(double scale) + { + var weightProgress = Math.Clamp((scale - 0.52) / 1.5, 0, 1); + var compactDelta = _isUltraCompactMode ? 40 : _isCompactMode ? 20 : 0; + + TitleTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, weightProgress)); + ModeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); + + SustainedReasonTextBlock.FontWeight = ToVariableWeight(Lerp(560, 690, weightProgress)); + TimeReasonTextBlock.FontWeight = ToVariableWeight(Lerp(560, 690, weightProgress)); + SegmentReasonTextBlock.FontWeight = ToVariableWeight(Lerp(560, 690, weightProgress)); + + SustainedMetricTextBlock.FontWeight = ToVariableWeight(Lerp(480, 600, weightProgress)); + TimeMetricTextBlock.FontWeight = ToVariableWeight(Lerp(480, 600, weightProgress)); + SegmentMetricTextBlock.FontWeight = ToVariableWeight(Lerp(480, 600, weightProgress)); + + SustainedLossTextBlock.FontWeight = ToVariableWeight(Lerp(640 + compactDelta, 800, weightProgress)); + TimeLossTextBlock.FontWeight = ToVariableWeight(Lerp(640 + compactDelta, 800, weightProgress)); + SegmentLossTextBlock.FontWeight = ToVariableWeight(Lerp(640 + compactDelta, 800, weightProgress)); + + TotalLossTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); + ScoreTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); + } + + private static double Lerp(double from, double to, double ratio) + { + ratio = Math.Clamp(ratio, 0, 1); + return from + ((to - from) * ratio); + } + + private static FontWeight ToVariableWeight(double weight) + { + return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs index 40c5e24..c0ef07a 100644 --- a/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyEnvironmentWidget.axaml.cs @@ -12,6 +12,7 @@ namespace LanMountainDesktop.Views.Components; public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget { private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly DispatcherTimer _uiTimer = new() @@ -25,6 +26,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private string _languageCode = "zh-CN"; private bool _isAttached; private bool _isOnActivePage = true; + private IDisposable? _monitoringLease; public StudyEnvironmentWidget() { @@ -61,6 +63,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg { _ = isEditMode; _isOnActivePage = isOnActivePage; + UpdateMonitoringLeaseState(); UpdateTimerState(); } @@ -74,7 +77,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg { _isAttached = true; ReloadDisplaySettings(); - _ = _studyAnalyticsService.StartOrResumeMonitoring(); + UpdateMonitoringLeaseState(); UpdateTimerState(); RefreshVisual(); } @@ -82,6 +85,8 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = false; + _monitoringLease?.Dispose(); + _monitoringLease = null; _uiTimer.Stop(); } @@ -107,6 +112,19 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg _uiTimer.Stop(); } + private void UpdateMonitoringLeaseState() + { + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } + private void ReloadDisplaySettings() { var snapshot = _settingsService.Load(); diff --git a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml new file mode 100644 index 0000000..df834d0 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs new file mode 100644 index 0000000..7722aaa --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyInterruptDensityWidget.axaml.cs @@ -0,0 +1,649 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; + +namespace LanMountainDesktop.Views.Components; + +public partial class StudyInterruptDensityWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +{ + private static readonly Color[] PrimaryColorCandidates = + { + Color.Parse("#FFEAF5FF"), + Color.Parse("#FFDDEEFF"), + Color.Parse("#FFCEE3FA"), + Color.Parse("#FF1B2E45"), + Color.Parse("#FF233A54"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF101C2A") + }; + + private static readonly Color[] SecondaryColorCandidates = + { + Color.Parse("#FFC7D9EC"), + Color.Parse("#FFBAD0E8"), + Color.Parse("#FFD9E8F6"), + Color.Parse("#FF2F4763"), + Color.Parse("#FF385673"), + Color.Parse("#FFEAF3FA"), + Color.Parse("#FF1A2C40") + }; + + private static readonly FontFamily MiSansVariableFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); + private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); + + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly DispatcherTimer _uiTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + + private double _currentCellSize = 48; + private bool _isAttached; + private bool _isOnActivePage = true; + private bool _isCompactMode; + private bool _isUltraCompactMode; + private string _languageCode = "zh-CN"; + private IDisposable? _monitoringLease; + + private enum DensityLevelKind + { + Calm = 0, + Normal = 1, + Frequent = 2, + Severe = 3 + } + + private readonly record struct InterruptDensityMetrics( + double DensityPerMin, + int SegmentCount, + TimeSpan Duration, + double ThresholdPerMin, + DensityLevelKind LevelKind); + + public StudyInterruptDensityWidget() + { + InitializeComponent(); + + _uiTimer.Tick += OnUiTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyVariableFontFamily(); + ReloadLanguageCode(); + ApplyCellSize(_currentCellSize); + RefreshVisual(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _ = isEditMode; + _isOnActivePage = isOnActivePage; + UpdateMonitoringLeaseState(); + UpdateTimerState(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ReloadLanguageCode(); + UpdateMonitoringLeaseState(); + UpdateTimerState(); + RefreshVisual(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _monitoringLease?.Dispose(); + _monitoringLease = null; + _uiTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateAdaptiveLayout(); + ApplyTypographyByBackground(ResolvePanelBackgroundColor()); + } + + private void OnUiTimerTick(object? sender, EventArgs e) + { + RefreshVisual(); + } + + private void UpdateTimerState() + { + if (_isAttached && _isOnActivePage) + { + if (!_uiTimer.IsEnabled) + { + _uiTimer.Start(); + } + + return; + } + + _uiTimer.Stop(); + } + + private void UpdateMonitoringLeaseState() + { + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } + + private void RefreshVisual() + { + var snapshot = _studyAnalyticsService.GetSnapshot(); + var panelColor = ResolvePanelBackgroundColor(); + ApplyTypographyByBackground(panelColor); + ApplyLocalizedLabels(); + + var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; + ModeTextBlock.Text = isSessionRunning + ? L("study.interrupt_density.mode.session", "Session") + : L("study.interrupt_density.mode.realtime", "Realtime"); + ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); + + var metrics = isSessionRunning + ? ComputeSessionDensity(snapshot) + : ComputeRealtimeDensity(snapshot); + + if (metrics is null) + { + ApplyUnavailable(snapshot.Config.MaxSegmentsPerMin); + return; + } + + var m = metrics.Value; + DensityValueTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.interrupt_density.density_value_format", "{0:F1}"), + m.DensityPerMin); + CountValueTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.interrupt_density.segment_count_value_format", "{0}"), + m.SegmentCount); + DurationValueTextBlock.Text = FormatDuration(m.Duration); + DensityLevelTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.interrupt_density.level_format", "Level {0}"), + ResolveLevelText(m.LevelKind)); + ThresholdTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.interrupt_density.threshold_format", "Threshold {0:F1}/min"), + m.ThresholdPerMin); + } + + private void ApplyLocalizedLabels() + { + TitleTextBlock.Text = L("study.interrupt_density.title", "Interrupt Density"); + DensityUnitTextBlock.Text = L("study.interrupt_density.unit", "/min"); + CountLabelTextBlock.Text = _isUltraCompactMode + ? L("study.interrupt_density.segment_count_short", "Count") + : L("study.interrupt_density.segment_count", "Interrupts"); + DurationLabelTextBlock.Text = _isUltraCompactMode + ? L("study.interrupt_density.duration_short", "Time") + : L("study.interrupt_density.duration", "Duration"); + } + + private void ApplyUnavailable(double thresholdPerMin) + { + var unavailable = L("study.interrupt_density.unavailable", "--"); + DensityValueTextBlock.Text = unavailable; + CountValueTextBlock.Text = unavailable; + DurationValueTextBlock.Text = unavailable; + DensityLevelTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.interrupt_density.level_format", "Level {0}"), + unavailable); + ThresholdTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.interrupt_density.threshold_format", "Threshold {0:F1}/min"), + Math.Max(1, thresholdPerMin)); + } + + private void UpdateAdaptiveLayout() + { + var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.4); + var widthScale = Bounds.Width > 1 ? Bounds.Width / 420d : cellScale; + var heightScale = Bounds.Height > 1 ? Bounds.Height / 220d : cellScale; + var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.52, 2.2); + var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.08), 0.52, 2.2); + + _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 350) || (Bounds.Height > 1 && Bounds.Height < 170); + _isUltraCompactMode = scale < 0.72 || (Bounds.Width > 1 && Bounds.Width < 295) || (Bounds.Height > 1 && Bounds.Height < 130); + + var compactMultiplier = _isUltraCompactMode ? 0.76 : _isCompactMode ? 0.88 : 1.0; + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.46, 12, 34)); + RootBorder.Padding = new Thickness( + Math.Clamp(12 * scale * compactMultiplier, 6, 18), + Math.Clamp(9 * scale * compactMultiplier, 5, 16)); + + ContentRootGrid.RowSpacing = _isUltraCompactMode + ? Math.Clamp(3 * scale, 2, 5) + : _isCompactMode + ? Math.Clamp(5 * scale, 3, 7) + : Math.Clamp(8 * scale, 4, 10); + HeaderGrid.ColumnSpacing = _isUltraCompactMode + ? Math.Clamp(6 * scale, 3, 8) + : Math.Clamp(8 * scale, 4, 10); + MainGrid.ColumnSpacing = _isUltraCompactMode + ? Math.Clamp(6 * scale, 3, 8) + : Math.Clamp(10 * scale, 5, 12); + StatsPanel.Spacing = _isUltraCompactMode + ? Math.Clamp(3 * scale, 1, 5) + : _isCompactMode + ? Math.Clamp(4 * scale, 2, 6) + : Math.Clamp(6 * scale, 3, 8); + + TitleTextBlock.FontSize = Math.Clamp(13 * scale, 9, 20); + ModeTextBlock.FontSize = Math.Clamp(11 * scale, 8, 16); + DensityValueTextBlock.FontSize = Math.Clamp(58 * scale, 18, 94); + DensityUnitTextBlock.FontSize = Math.Clamp(15 * scale, 9, 24); + DensityLevelTextBlock.FontSize = Math.Clamp(13 * scale, 8, 18); + CountLabelTextBlock.FontSize = Math.Clamp(11 * scale, 8, 14); + DurationLabelTextBlock.FontSize = Math.Clamp(11 * scale, 8, 14); + CountValueTextBlock.FontSize = Math.Clamp(22 * scale, 10, 36); + DurationValueTextBlock.FontSize = Math.Clamp(20 * scale, 9, 32); + ThresholdTextBlock.FontSize = Math.Clamp(11 * scale, 8, 14); + + DensityValueStack.Spacing = Math.Clamp(6 * scale, 2, 10); + DensityStackPanel.Spacing = _isUltraCompactMode ? Math.Clamp(1.5 * scale, 1, 3) : Math.Clamp(3 * scale, 1.5, 5); + + ModeBadgeBorder.Padding = new Thickness( + Math.Clamp(8 * scale * compactMultiplier, 4, 12), + Math.Clamp(3 * scale * compactMultiplier, 1.5, 6)); + ModeBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(8 * scale, 4, 12)); + + var cardPadding = new Thickness( + Math.Clamp(10 * scale * compactMultiplier, 5, 14), + Math.Clamp(6 * scale * compactMultiplier, 3, 9)); + CountCardBorder.Padding = cardPadding; + DurationCardBorder.Padding = cardPadding; + + TitleTextBlock.IsVisible = !_isUltraCompactMode; + ThresholdTextBlock.IsVisible = !_isUltraCompactMode; + DensityUnitTextBlock.IsVisible = !_isUltraCompactMode; + CountLabelTextBlock.IsVisible = !_isUltraCompactMode; + DurationLabelTextBlock.IsVisible = !_isUltraCompactMode; + + ApplyVariableWeights(scale); + ApplyLocalizedLabels(); + } + + private void ApplyTypographyByBackground(Color panelColor) + { + var samples = BuildPanelBackgroundSamples(panelColor); + var primary = CreateAdaptiveBrush(samples, PrimaryColorCandidates, minContrast: 4.5); + var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5); + + TitleTextBlock.Foreground = secondary; + DensityUnitTextBlock.Foreground = secondary; + CountLabelTextBlock.Foreground = secondary; + DurationLabelTextBlock.Foreground = secondary; + ThresholdTextBlock.Foreground = secondary; + + DensityValueTextBlock.Foreground = primary; + DensityLevelTextBlock.Foreground = primary; + CountValueTextBlock.Foreground = primary; + DurationValueTextBlock.Foreground = primary; + } + + private void ApplyModeBadgeColor(Color panelColor, Color baseColor) + { + var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate)); + var badgeAlpha = panelLuminance > 0.58 + ? (byte)0xE2 + : panelLuminance > 0.46 + ? (byte)0xD8 + : (byte)0xC8; + + var badgeColor = Color.FromArgb(badgeAlpha, baseColor.R, baseColor.G, baseColor.B); + var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate)); + + ModeBadgeBorder.Background = new SolidColorBrush(badgeColor); + ModeBadgeBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF)); + ModeTextBlock.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, PrimaryColorCandidates, minContrast: 4.5); + } + + private static InterruptDensityMetrics? ComputeRealtimeDensity(StudyAnalyticsSnapshot snapshot) + { + var points = snapshot.RealtimeBuffer; + if (points.Count < 2) + { + return null; + } + + var weightedDurationMs = 0d; + var segmentCount = 0; + var segmentOpen = false; + DateTimeOffset? lastOverThresholdAt = null; + + for (var i = 0; i < points.Count - 1; i++) + { + var current = points[i]; + var next = points[i + 1]; + var dtMs = (next.Timestamp - current.Timestamp).TotalMilliseconds; + if (dtMs <= 0) + { + continue; + } + + weightedDurationMs += dtMs; + + if (current.IsOverThreshold) + { + if (segmentOpen) + { + lastOverThresholdAt = current.Timestamp; + } + else + { + var canMerge = lastOverThresholdAt.HasValue && + (current.Timestamp - lastOverThresholdAt.Value).TotalMilliseconds <= snapshot.Config.SegmentMergeGapMs; + if (!canMerge) + { + segmentCount++; + } + + segmentOpen = true; + lastOverThresholdAt = current.Timestamp; + } + } + else if (segmentOpen && lastOverThresholdAt.HasValue) + { + var silentGapMs = (current.Timestamp - lastOverThresholdAt.Value).TotalMilliseconds; + if (silentGapMs > snapshot.Config.SegmentMergeGapMs) + { + segmentOpen = false; + } + } + } + + if (weightedDurationMs <= 0) + { + weightedDurationMs = points.Count * snapshot.Config.FrameMs; + } + + if (weightedDurationMs <= Math.Max(300, snapshot.Config.FrameMs * 3)) + { + return null; + } + + var minutes = Math.Max(1d / 60d, weightedDurationMs / 60000d); + var density = Math.Max(0, segmentCount / minutes); + var threshold = Math.Max(1, snapshot.Config.MaxSegmentsPerMin); + var levelKind = ResolveLevelKind(density, threshold); + + return new InterruptDensityMetrics( + DensityPerMin: Math.Round(density, 2), + SegmentCount: Math.Max(0, segmentCount), + Duration: TimeSpan.FromMilliseconds(weightedDurationMs), + ThresholdPerMin: threshold, + LevelKind: levelKind); + } + + private static InterruptDensityMetrics? ComputeSessionDensity(StudyAnalyticsSnapshot snapshot) + { + var metrics = snapshot.Session.Metrics; + if (metrics.EffectiveDuration.TotalMilliseconds <= Math.Max(300, snapshot.Config.FrameMs * 3)) + { + return null; + } + + var minutes = Math.Max(1d / 60d, metrics.EffectiveDuration.TotalMinutes); + var density = Math.Max(0, metrics.TotalSegmentCount / minutes); + var threshold = Math.Max(1, snapshot.Config.MaxSegmentsPerMin); + var levelKind = ResolveLevelKind(density, threshold); + + return new InterruptDensityMetrics( + DensityPerMin: Math.Round(density, 2), + SegmentCount: Math.Max(0, metrics.TotalSegmentCount), + Duration: metrics.EffectiveDuration, + ThresholdPerMin: threshold, + LevelKind: levelKind); + } + + private static DensityLevelKind ResolveLevelKind(double densityPerMin, double thresholdPerMin) + { + var ratio = densityPerMin / Math.Max(1, thresholdPerMin); + if (ratio < 0.33) + { + return DensityLevelKind.Calm; + } + + if (ratio < 0.66) + { + return DensityLevelKind.Normal; + } + + if (ratio < 1.0) + { + return DensityLevelKind.Frequent; + } + + return DensityLevelKind.Severe; + } + + private string ResolveLevelText(DensityLevelKind levelKind) + { + return levelKind switch + { + DensityLevelKind.Calm => L("study.interrupt_density.level.calm", "Calm"), + DensityLevelKind.Normal => L("study.interrupt_density.level.normal", "Normal"), + DensityLevelKind.Frequent => L("study.interrupt_density.level.frequent", "Frequent"), + DensityLevelKind.Severe => L("study.interrupt_density.level.severe", "Severe"), + _ => L("study.interrupt_density.level.normal", "Normal") + }; + } + + private string FormatDuration(TimeSpan duration) + { + if (duration.TotalHours >= 1) + { + return duration.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture); + } + + return duration.ToString(@"mm\:ss", CultureInfo.InvariantCulture); + } + + private Color ResolvePanelBackgroundColor() + { + if (RootBorder.Background is ISolidColorBrush solidBackground) + { + return solidBackground.Color; + } + + if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, out var resource) && + resource is ISolidColorBrush solidBrush) + { + return solidBrush.Color; + } + + return Color.Parse("#FF1E293B"); + } + + private void ReloadLanguageCode() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + + private void ApplyVariableFontFamily() + { + TitleTextBlock.FontFamily = MiSansVariableFontFamily; + ModeTextBlock.FontFamily = MiSansVariableFontFamily; + DensityValueTextBlock.FontFamily = MiSansVariableFontFamily; + DensityUnitTextBlock.FontFamily = MiSansVariableFontFamily; + DensityLevelTextBlock.FontFamily = MiSansVariableFontFamily; + CountLabelTextBlock.FontFamily = MiSansVariableFontFamily; + CountValueTextBlock.FontFamily = MiSansVariableFontFamily; + DurationLabelTextBlock.FontFamily = MiSansVariableFontFamily; + DurationValueTextBlock.FontFamily = MiSansVariableFontFamily; + ThresholdTextBlock.FontFamily = MiSansVariableFontFamily; + } + + private void ApplyVariableWeights(double scale) + { + var weightProgress = Math.Clamp((scale - 0.52) / 1.5, 0, 1); + var compactDelta = _isUltraCompactMode ? 40 : _isCompactMode ? 20 : 0; + + TitleTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, weightProgress)); + ModeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); + DensityValueTextBlock.FontWeight = ToVariableWeight(Lerp(660 + compactDelta, 820, weightProgress)); + DensityUnitTextBlock.FontWeight = ToVariableWeight(Lerp(520, 640, weightProgress)); + DensityLevelTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); + CountLabelTextBlock.FontWeight = ToVariableWeight(Lerp(520, 620, weightProgress)); + CountValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 780, weightProgress)); + DurationLabelTextBlock.FontWeight = ToVariableWeight(Lerp(520, 620, weightProgress)); + DurationValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress)); + ThresholdTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + } + + private static double Lerp(double from, double to, double ratio) + { + ratio = Math.Clamp(ratio, 0, 1); + return from + ((to - from) * ratio); + } + + private static FontWeight ToVariableWeight(double weight) + { + return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); + } + + 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 CreateAdaptiveBrush( + IReadOnlyList backgroundSamples, + IReadOnlyList colorCandidates, + double minContrast) + { + if (colorCandidates.Count == 0) + { + return new SolidColorBrush(Color.Parse("#FFFFFFFF")); + } + + for (var i = 0; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + if (MinContrastRatio(candidate, backgroundSamples) >= minContrast) + { + return new SolidColorBrush(candidate); + } + } + + var best = colorCandidates[0]; + var bestContrast = MinContrastRatio(best, backgroundSamples); + for (var i = 1; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + var contrast = MinContrastRatio(candidate, backgroundSamples); + if (contrast > bestContrast) + { + best = candidate; + bestContrast = contrast; + } + } + + return new SolidColorBrush(best); + } + + 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 string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs index 2de6284..39c4f30 100644 --- a/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyNoiseCurveWidget.axaml.cs @@ -54,6 +54,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge private readonly object _snapshotSync = new(); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly DispatcherTimer _renderTimer = new() @@ -69,6 +70,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge private bool _isOnActivePage = true; private bool _isSubscribed; private int _framesSinceCompaction; + private IDisposable? _monitoringLease; private enum StatusVisualKind { @@ -130,6 +132,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge { _ = isEditMode; _isOnActivePage = isOnActivePage; + UpdateMonitoringLeaseState(); UpdateRenderLoopState(); } @@ -144,7 +147,7 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge _isSubscribed = true; } - _ = _studyAnalyticsService.StartOrResumeMonitoring(); + UpdateMonitoringLeaseState(); lock (_snapshotSync) { @@ -158,6 +161,8 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = false; + _monitoringLease?.Dispose(); + _monitoringLease = null; _renderTimer.Stop(); if (_isSubscribed) @@ -224,6 +229,19 @@ public partial class StudyNoiseCurveWidget : UserControl, IDesktopComponentWidge _renderTimer.Stop(); } + private void UpdateMonitoringLeaseState() + { + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } + private void ApplySnapshot(StudyAnalyticsSnapshot snapshot) { var panelColor = ResolvePanelBackgroundColor(); diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs new file mode 100644 index 0000000..059fb26 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionScatterChartControl.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Views.Components; + +public sealed class StudyNoiseDistributionScatterChartControl : Control +{ + private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96")); + private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1")); + private static readonly Pen GridPen = new(GridBrush, 1); + private static readonly Pen AxisPen = new(AxisBrush, 1.1); + + private static readonly IBrush QuietPointBrush = new SolidColorBrush(Color.Parse("#FF34D399")); + private static readonly IBrush NormalPointBrush = new SolidColorBrush(Color.Parse("#FF60A5FA")); + private static readonly IBrush NoisyPointBrush = new SolidColorBrush(Color.Parse("#FFF59E0B")); + private static readonly IBrush ExtremePointBrush = new SolidColorBrush(Color.Parse("#FFEF4444")); + + private IReadOnlyList _points = Array.Empty(); + private double _baselineDb = 45; + + public void UpdateSeries(IReadOnlyList? points, double baselineDb) + { + _points = points ?? Array.Empty(); + _baselineDb = Math.Clamp(baselineDb, 20, 85); + InvalidateVisual(); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = Bounds; + if (bounds.Width <= 2 || bounds.Height <= 2) + { + return; + } + + var plot = new Rect( + x: 1, + y: 1, + width: Math.Max(1, bounds.Width - 2), + height: Math.Max(1, bounds.Height - 2)); + + DrawGrid(context, plot); + + if (_points.Count == 0) + { + return; + } + + var start = _points[0].Timestamp; + var end = _points[^1].Timestamp; + var totalTicks = Math.Max(1, (end - start).Ticks); + + var maxRenderPoints = Math.Clamp((int)Math.Floor(plot.Width * 1.5), 80, 520); + var step = Math.Max(1, _points.Count / Math.Max(1, maxRenderPoints)); + var radius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 88d, 1.4, 3.8); + + for (var i = 0; i < _points.Count; i += step) + { + var point = _points[i]; + var level = ResolveLevel(point.DisplayDb, _baselineDb); + var x = MapX(plot, point.Timestamp, start, totalTicks); + var y = MapY(plot, level, point.Timestamp); + context.DrawEllipse(GetLevelBrush(level), pen: null, center: new Point(x, y), radiusX: radius, radiusY: radius); + } + + // Ensure latest point is always visible. + var latest = _points[^1]; + var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb); + var latestX = MapX(plot, latest.Timestamp, start, totalTicks); + var latestY = MapY(plot, latestLevel, latest.Timestamp); + context.DrawEllipse(GetLevelBrush(latestLevel), pen: new Pen(Brushes.White, 1), center: new Point(latestX, latestY), radiusX: radius + 0.8, radiusY: radius + 0.8); + } + + private static void DrawGrid(DrawingContext context, Rect plot) + { + const int verticalDivisions = 4; + + for (var i = 0; i <= verticalDivisions; i++) + { + var x = plot.Left + plot.Width * (i / (double)verticalDivisions); + context.DrawLine(GridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + } + + for (var i = 0; i <= 4; i++) + { + var y = plot.Top + plot.Height * (i / 4d); + context.DrawLine(GridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + } + + context.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom)); + context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom)); + } + + private static double MapX(Rect plot, DateTimeOffset timestamp, DateTimeOffset start, long totalTicks) + { + var offsetTicks = Math.Clamp((timestamp - start).Ticks, 0, totalTicks); + return plot.Left + plot.Width * (offsetTicks / (double)totalTicks); + } + + private static double MapY(Rect plot, NoiseDistributionLevel level, DateTimeOffset timestamp) + { + // 4 bands: quiet(bottom) -> extreme(top). Add deterministic jitter in each band. + var bandHeight = plot.Height / 4d; + var levelIndex = level switch + { + NoiseDistributionLevel.Quiet => 0, + NoiseDistributionLevel.Normal => 1, + NoiseDistributionLevel.Noisy => 2, + NoiseDistributionLevel.Extreme => 3, + _ => 1 + }; + + var centerY = plot.Bottom - ((levelIndex + 0.5) * bandHeight); + var jitter = ComputeJitter(timestamp.Ticks) * bandHeight * 0.26; + return Math.Clamp(centerY + jitter, plot.Top + 1.5, plot.Bottom - 1.5); + } + + private static double ComputeJitter(long ticks) + { + // Deterministic pseudo-random value in [-1, 1] to avoid overlap without animation noise. + var value = (ulong)ticks; + value ^= value >> 33; + value *= 0xff51afd7ed558ccdUL; + value ^= value >> 33; + value *= 0xc4ceb9fe1a85ec53UL; + value ^= value >> 33; + var normalized = (value & 0xFFFF) / 65535d; + return (normalized * 2d) - 1d; + } + + private static NoiseDistributionLevel ResolveLevel(double displayDb, double baselineDb) + { + var quietUpper = baselineDb; + var normalUpper = baselineDb + 10d; + var noisyUpper = baselineDb + 20d; + + if (displayDb < quietUpper) + { + return NoiseDistributionLevel.Quiet; + } + + if (displayDb < normalUpper) + { + return NoiseDistributionLevel.Normal; + } + + if (displayDb < noisyUpper) + { + return NoiseDistributionLevel.Noisy; + } + + return NoiseDistributionLevel.Extreme; + } + + private static IBrush GetLevelBrush(NoiseDistributionLevel level) + { + return level switch + { + NoiseDistributionLevel.Quiet => QuietPointBrush, + NoiseDistributionLevel.Normal => NormalPointBrush, + NoiseDistributionLevel.Noisy => NoisyPointBrush, + NoiseDistributionLevel.Extreme => ExtremePointBrush, + _ => NormalPointBrush + }; + } +} + +public enum NoiseDistributionLevel +{ + Quiet = 0, + Normal = 1, + Noisy = 2, + Extreme = 3 +} diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml new file mode 100644 index 0000000..4910b8b --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs new file mode 100644 index 0000000..95a5480 --- /dev/null +++ b/LanMountainDesktop/Views/Components/StudyNoiseDistributionWidget.axaml.cs @@ -0,0 +1,602 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; + +namespace LanMountainDesktop.Views.Components; + +public partial class StudyNoiseDistributionWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget +{ + private static readonly Color[] ValueColorCandidates = + { + Color.Parse("#FFEAF5FF"), + Color.Parse("#FFDDEEFF"), + Color.Parse("#FFCEE3FA"), + Color.Parse("#FF1B2E45"), + Color.Parse("#FF233A54"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF101C2A") + }; + + private static readonly Color[] SecondaryColorCandidates = + { + Color.Parse("#FFC7D9EC"), + Color.Parse("#FFBAD0E8"), + Color.Parse("#FFD9E8F6"), + Color.Parse("#FF2F4763"), + Color.Parse("#FF385673"), + Color.Parse("#FFEAF3FA"), + Color.Parse("#FF1A2C40") + }; + + private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); + private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); + private static readonly FontFamily MiSansVariableFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + + private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly DispatcherTimer _uiTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + + private double _currentCellSize = 48; + private string _languageCode = "zh-CN"; + private bool _isAttached; + private bool _isOnActivePage = true; + private bool _isCompactMode; + private bool _isUltraCompactMode; + private IDisposable? _monitoringLease; + + private readonly record struct DistributionStats( + NoiseDistributionLevel LatestLevel, + NoiseDistributionLevel DominantLevel, + TimeSpan Duration, + int QuietCount, + int NormalCount, + int NoisyCount, + int ExtremeCount); + + public StudyNoiseDistributionWidget() + { + InitializeComponent(); + + _uiTimer.Tick += OnUiTimerTick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyVariableFontFamily(); + ReloadLanguageCode(); + ApplyCellSize(_currentCellSize); + ApplyDefaultXAxisLabels(); + ApplyLocalizedAxisLabels(); + RefreshVisual(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode) + { + _ = isEditMode; + _isOnActivePage = isOnActivePage; + UpdateMonitoringLeaseState(); + UpdateTimerState(); + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + ReloadLanguageCode(); + UpdateMonitoringLeaseState(); + UpdateTimerState(); + RefreshVisual(); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _monitoringLease?.Dispose(); + _monitoringLease = null; + _uiTimer.Stop(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateAdaptiveLayout(); + ApplyTypographyByBackground(ResolvePanelBackgroundColor()); + } + + private void OnUiTimerTick(object? sender, EventArgs e) + { + RefreshVisual(); + } + + private void UpdateTimerState() + { + if (_isAttached && _isOnActivePage) + { + if (!_uiTimer.IsEnabled) + { + _uiTimer.Start(); + } + + return; + } + + _uiTimer.Stop(); + } + + private void UpdateMonitoringLeaseState() + { + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } + + private void RefreshVisual() + { + var snapshot = _studyAnalyticsService.GetSnapshot(); + var panelColor = ResolvePanelBackgroundColor(); + ApplyTypographyByBackground(panelColor); + + TitleTextBlock.Text = L("study.noise_distribution.title", "Noise Level Distribution"); + ApplyLocalizedAxisLabels(); + + var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; + ModeTextBlock.Text = isSessionRunning + ? L("study.noise_distribution.mode.session", "Session") + : L("study.noise_distribution.mode.realtime", "Realtime"); + ApplyModeBadgeColor(panelColor, isSessionRunning ? Color.Parse("#FF0F6B49") : Color.Parse("#FF2F5DA8")); + + ChartControl.UpdateSeries(snapshot.RealtimeBuffer, snapshot.Config.BaselineDb); + UpdateXAxisLabels(snapshot); + + var stats = ComputeDistributionStats(snapshot.RealtimeBuffer, snapshot.Config.BaselineDb); + if (stats is null) + { + SummaryTextBlock.Text = string.Format( + CultureInfo.InvariantCulture, + L("study.noise_distribution.summary.latest_format", "Latest: {0}"), + L("study.environment.value.unavailable", "--")); + return; + } + + var distribution = stats.Value; + var dominant = ResolveLevelText(distribution.DominantLevel); + var latest = ResolveLevelText(distribution.LatestLevel); + + SummaryTextBlock.Text = _isUltraCompactMode + ? string.Format( + CultureInfo.InvariantCulture, + L("study.noise_distribution.summary.compact_format", "Main {0} · New {1}"), + dominant, + latest) + : string.Format( + CultureInfo.InvariantCulture, + "{0} · {1}", + string.Format(CultureInfo.InvariantCulture, L("study.noise_distribution.summary.mainly_format", "Mainly: {0}"), dominant), + string.Format(CultureInfo.InvariantCulture, L("study.noise_distribution.summary.latest_format", "Latest: {0}"), latest)); + } + + private static DistributionStats? ComputeDistributionStats(IReadOnlyList points, double baselineDb) + { + if (points.Count < 2) + { + return null; + } + + var start = points[0].Timestamp; + var end = points[^1].Timestamp; + var duration = end - start; + if (duration.TotalMilliseconds <= 300) + { + return null; + } + + var quiet = 0; + var normal = 0; + var noisy = 0; + var extreme = 0; + + for (var i = 0; i < points.Count; i++) + { + switch (ResolveLevel(points[i].DisplayDb, baselineDb)) + { + case NoiseDistributionLevel.Quiet: + quiet++; + break; + case NoiseDistributionLevel.Normal: + normal++; + break; + case NoiseDistributionLevel.Noisy: + noisy++; + break; + case NoiseDistributionLevel.Extreme: + extreme++; + break; + } + } + + var dominantLevel = NoiseDistributionLevel.Quiet; + var dominantCount = quiet; + if (normal > dominantCount) + { + dominantLevel = NoiseDistributionLevel.Normal; + dominantCount = normal; + } + + if (noisy > dominantCount) + { + dominantLevel = NoiseDistributionLevel.Noisy; + dominantCount = noisy; + } + + if (extreme > dominantCount) + { + dominantLevel = NoiseDistributionLevel.Extreme; + } + + var latestLevel = ResolveLevel(points[^1].DisplayDb, baselineDb); + return new DistributionStats( + LatestLevel: latestLevel, + DominantLevel: dominantLevel, + Duration: duration, + QuietCount: quiet, + NormalCount: normal, + NoisyCount: noisy, + ExtremeCount: extreme); + } + + private static NoiseDistributionLevel ResolveLevel(double displayDb, double baselineDb) + { + var quietUpper = baselineDb; + var normalUpper = baselineDb + 10d; + var noisyUpper = baselineDb + 20d; + + if (displayDb < quietUpper) + { + return NoiseDistributionLevel.Quiet; + } + + if (displayDb < normalUpper) + { + return NoiseDistributionLevel.Normal; + } + + if (displayDb < noisyUpper) + { + return NoiseDistributionLevel.Noisy; + } + + return NoiseDistributionLevel.Extreme; + } + + private void UpdateAdaptiveLayout() + { + var cellScale = Math.Clamp(_currentCellSize / 48d, 0.76, 2.4); + var widthScale = Bounds.Width > 1 ? Bounds.Width / 520d : cellScale; + var heightScale = Bounds.Height > 1 ? Bounds.Height / 240d : cellScale; + var boundsScale = Math.Clamp(Math.Min(widthScale, heightScale), 0.52, 2.3); + var scale = Math.Clamp(Math.Min(cellScale, boundsScale * 1.06), 0.52, 2.3); + + _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 360) || (Bounds.Height > 1 && Bounds.Height < 180); + _isUltraCompactMode = scale < 0.74 || (Bounds.Width > 1 && Bounds.Width < 300) || (Bounds.Height > 1 && Bounds.Height < 142); + + var compactMultiplier = _isUltraCompactMode ? 0.76 : _isCompactMode ? 0.88 : 1.0; + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.44, 12, 34)); + RootBorder.Padding = new Thickness( + Math.Clamp(12 * scale * compactMultiplier, 6, 18), + Math.Clamp(9 * scale * compactMultiplier, 5, 16)); + + ContentRootGrid.RowSpacing = _isUltraCompactMode + ? Math.Clamp(4 * scale, 2, 5) + : _isCompactMode + ? Math.Clamp(6 * scale, 3, 8) + : Math.Clamp(8 * scale, 4, 11); + HeaderGrid.ColumnSpacing = _isUltraCompactMode + ? Math.Clamp(5 * scale, 2, 7) + : Math.Clamp(8 * scale, 4, 10); + + TitleTextBlock.FontSize = Math.Clamp(13 * scale, 9, 22); + SummaryTextBlock.FontSize = Math.Clamp(12 * scale, 8, 20); + ModeTextBlock.FontSize = Math.Clamp(11 * scale, 8, 18); + + YExtremeTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + YNoisyTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + YNormalTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + YQuietTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + XLeftTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + XCenterTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + XRightTextBlock.FontSize = Math.Clamp(10 * scale, 8, 16); + + ModeBadgeBorder.Padding = new Thickness( + Math.Clamp(8 * scale * compactMultiplier, 4, 12), + Math.Clamp(3 * scale * compactMultiplier, 1.6, 6)); + ModeBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(8 * scale, 4, 12)); + + TitleTextBlock.IsVisible = !_isUltraCompactMode; + SummaryTextBlock.IsVisible = true; + + ApplyVariableWeights(scale); + } + + private void ApplyTypographyByBackground(Color panelColor) + { + var samples = BuildPanelBackgroundSamples(panelColor); + var primary = CreateAdaptiveBrush(samples, ValueColorCandidates, minContrast: 4.5); + var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5); + + TitleTextBlock.Foreground = secondary; + YExtremeTextBlock.Foreground = secondary; + YNoisyTextBlock.Foreground = secondary; + YNormalTextBlock.Foreground = secondary; + YQuietTextBlock.Foreground = secondary; + XLeftTextBlock.Foreground = secondary; + XCenterTextBlock.Foreground = secondary; + XRightTextBlock.Foreground = secondary; + + SummaryTextBlock.Foreground = primary; + } + + private void ApplyModeBadgeColor(Color panelColor, Color baseColor) + { + var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate)); + var badgeAlpha = panelLuminance > 0.58 + ? (byte)0xE2 + : panelLuminance > 0.46 + ? (byte)0xD8 + : (byte)0xC8; + + var badgeColor = Color.FromArgb(badgeAlpha, baseColor.R, baseColor.G, baseColor.B); + var badgeComposite = ToOpaqueAgainst(badgeColor, ToOpaqueAgainst(panelColor, DarkSubstrate)); + + ModeBadgeBorder.Background = new SolidColorBrush(badgeColor); + ModeBadgeBorder.BorderBrush = new SolidColorBrush(Color.FromArgb(0x96, 0xFF, 0xFF, 0xFF)); + ModeTextBlock.Foreground = CreateAdaptiveBrush(new[] { badgeComposite }, ValueColorCandidates, minContrast: 4.5); + } + + private Color ResolvePanelBackgroundColor() + { + if (RootBorder.Background is ISolidColorBrush solidBackground) + { + return solidBackground.Color; + } + + if (Resources.TryGetResource("AdaptiveGlassStrongBackgroundBrush", ActualThemeVariant, 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 CreateAdaptiveBrush( + IReadOnlyList backgroundSamples, + IReadOnlyList colorCandidates, + double minContrast) + { + if (colorCandidates.Count == 0) + { + return new SolidColorBrush(Color.Parse("#FFFFFFFF")); + } + + for (var i = 0; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + if (MinContrastRatio(candidate, backgroundSamples) >= minContrast) + { + return new SolidColorBrush(candidate); + } + } + + var best = colorCandidates[0]; + var bestContrast = MinContrastRatio(best, backgroundSamples); + for (var i = 1; i < colorCandidates.Count; i++) + { + var candidate = colorCandidates[i]; + var contrast = MinContrastRatio(candidate, backgroundSamples); + if (contrast > bestContrast) + { + best = candidate; + bestContrast = contrast; + } + } + + return new SolidColorBrush(best); + } + + 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_distribution.axis.now", "Now"); + } + + private void ApplyDefaultXAxisLabels() + { + XLeftTextBlock.Text = "-12s"; + XCenterTextBlock.Text = "-6s"; + XRightTextBlock.Text = L("study.noise_distribution.axis.now", "Now"); + } + + private void ApplyLocalizedAxisLabels() + { + YExtremeTextBlock.Text = L("study.noise_distribution.axis.extreme", "Extreme"); + YNoisyTextBlock.Text = L("study.noise_distribution.axis.noisy", "Noisy"); + YNormalTextBlock.Text = L("study.noise_distribution.axis.normal", "Normal"); + YQuietTextBlock.Text = L("study.noise_distribution.axis.quiet", "Quiet"); + } + + private string ResolveLevelText(NoiseDistributionLevel level) + { + return level switch + { + NoiseDistributionLevel.Quiet => L("study.noise_distribution.level.quiet", "Quiet"), + NoiseDistributionLevel.Normal => L("study.noise_distribution.level.normal", "Normal"), + NoiseDistributionLevel.Noisy => L("study.noise_distribution.level.noisy", "Noisy"), + NoiseDistributionLevel.Extreme => L("study.noise_distribution.level.extreme", "Extreme"), + _ => L("study.noise_distribution.level.normal", "Normal") + }; + } + + private void ReloadLanguageCode() + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + + private void ApplyVariableFontFamily() + { + TitleTextBlock.FontFamily = MiSansVariableFontFamily; + SummaryTextBlock.FontFamily = MiSansVariableFontFamily; + ModeTextBlock.FontFamily = MiSansVariableFontFamily; + YExtremeTextBlock.FontFamily = MiSansVariableFontFamily; + YNoisyTextBlock.FontFamily = MiSansVariableFontFamily; + YNormalTextBlock.FontFamily = MiSansVariableFontFamily; + YQuietTextBlock.FontFamily = MiSansVariableFontFamily; + XLeftTextBlock.FontFamily = MiSansVariableFontFamily; + XCenterTextBlock.FontFamily = MiSansVariableFontFamily; + XRightTextBlock.FontFamily = MiSansVariableFontFamily; + } + + private void ApplyVariableWeights(double scale) + { + var weightProgress = Math.Clamp((scale - 0.52) / 1.5, 0, 1); + var compactDelta = _isUltraCompactMode ? 40 : _isCompactMode ? 20 : 0; + + TitleTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, weightProgress)); + SummaryTextBlock.FontWeight = ToVariableWeight(Lerp(550 + compactDelta, 700, weightProgress)); + ModeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); + YExtremeTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + YNoisyTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + YNormalTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + YQuietTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + XLeftTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + XCenterTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + XRightTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); + } + + private static double Lerp(double from, double to, double ratio) + { + ratio = Math.Clamp(ratio, 0, 1); + return from + ((to - from) * ratio); + } + + private static FontWeight ToVariableWeight(double weight) + { + return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } +} diff --git a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml index c6f03ab..f33a080 100644 --- a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml +++ b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml @@ -61,65 +61,87 @@ FontWeight="SemiBold" MaxLines="1" TextTrimming="CharacterEllipsis" - VerticalAlignment="Center" + VerticalAlignment="Bottom" + Margin="0,2,0,4" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" /> - - - - + + + + + + - - - - + + + + + + - - - - + + + + + + diff --git a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs index af241b8..efc3a0c 100644 --- a/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/StudyScoreOverviewWidget.axaml.cs @@ -41,6 +41,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi private static readonly FontFamily MiSansVariableFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); + private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly DispatcherTimer _uiTimer = new() @@ -55,7 +56,9 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi private bool _isOnActivePage = true; private bool _isCompactMode; private bool _isUltraCompactMode; + private bool _isExpandedMode; private string _languageCode = "zh-CN"; + private IDisposable? _monitoringLease; public StudyScoreOverviewWidget() { @@ -82,6 +85,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi { _ = isEditMode; _isOnActivePage = isOnActivePage; + UpdateMonitoringLeaseState(); UpdateTimerState(); } @@ -89,7 +93,7 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi { _isAttached = true; ReloadLanguageCode(); - _ = _studyAnalyticsService.StartOrResumeMonitoring(); + UpdateMonitoringLeaseState(); UpdateTimerState(); RefreshVisual(); } @@ -97,6 +101,8 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { _isAttached = false; + _monitoringLease?.Dispose(); + _monitoringLease = null; _uiTimer.Stop(); } @@ -126,6 +132,19 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi _uiTimer.Stop(); } + private void UpdateMonitoringLeaseState() + { + var shouldMonitor = _isAttached && _isOnActivePage; + if (shouldMonitor) + { + _monitoringLease ??= _monitoringLeaseCoordinator.AcquireLease(); + return; + } + + _monitoringLease?.Dispose(); + _monitoringLease = null; + } + private void RefreshVisual() { var snapshot = _studyAnalyticsService.GetSnapshot(); @@ -205,18 +224,22 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi _isCompactMode = scale < 0.92 || (Bounds.Width > 1 && Bounds.Width < 320) || (Bounds.Height > 1 && Bounds.Height < 300); _isUltraCompactMode = scale < 0.72 || (Bounds.Width > 1 && Bounds.Width < 270) || (Bounds.Height > 1 && Bounds.Height < 250); + _isExpandedMode = !_isCompactMode && (scale > 1.12 || (Bounds.Width > 1 && Bounds.Width >= 430) || (Bounds.Height > 1 && Bounds.Height >= 430)); var compactMultiplier = _isUltraCompactMode ? 0.76 : _isCompactMode ? 0.88 : 1.0; + var expandedMultiplier = _isExpandedMode ? 1.12 : 1.0; RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.50, 14, 42)); RootBorder.Padding = new Thickness( - Math.Clamp(16 * scale * compactMultiplier, 8, 24), - Math.Clamp(14 * scale * compactMultiplier, 6, 20)); + Math.Clamp(16 * scale * compactMultiplier * expandedMultiplier, 8, 30), + Math.Clamp(14 * scale * compactMultiplier * expandedMultiplier, 6, 26)); ContentRootGrid.RowSpacing = _isUltraCompactMode ? Math.Clamp(4 * scale, 2, 5) : _isCompactMode ? Math.Clamp(6 * scale, 3, 7) - : Math.Clamp(8 * scale, 4, 10); + : _isExpandedMode + ? Math.Clamp(10 * scale, 6, 16) + : Math.Clamp(8 * scale, 4, 10); TopRowGrid.ColumnSpacing = _isUltraCompactMode ? Math.Clamp(6 * scale, 3, 8) : Math.Clamp(8 * scale, 4, 10); @@ -224,29 +247,53 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi ? Math.Clamp(5 * scale, 3, 7) : _isCompactMode ? Math.Clamp(7 * scale, 4, 9) - : Math.Clamp(10 * scale, 6, 12); + : _isExpandedMode + ? Math.Clamp(14 * scale, 8, 20) + : Math.Clamp(10 * scale, 6, 12); - var headlineFactor = _isUltraCompactMode ? 0.62 : _isCompactMode ? 0.80 : 1.0; - var statFactor = _isUltraCompactMode ? 0.74 : _isCompactMode ? 0.90 : 1.0; - var labelFactor = _isUltraCompactMode ? 0.84 : _isCompactMode ? 0.92 : 1.0; + var headlineFactor = _isUltraCompactMode ? 0.62 : _isCompactMode ? 0.80 : _isExpandedMode ? 1.22 : 1.02; + var statFactor = _isUltraCompactMode ? 0.74 : _isCompactMode ? 0.90 : _isExpandedMode ? 1.36 : 1.04; + var labelFactor = _isUltraCompactMode ? 0.84 : _isCompactMode ? 0.92 : _isExpandedMode ? 1.14 : 1.0; - TitleTextBlock.FontSize = Math.Clamp(14 * scale * labelFactor, 9, 24); - ModeTextBlock.FontSize = Math.Clamp(12 * scale * labelFactor, 8, 18); - CurrentLabelTextBlock.FontSize = Math.Clamp(12 * scale * labelFactor, 8, 18); - CurrentScoreTextBlock.FontSize = Math.Clamp(76 * scale * headlineFactor, 22, 140); + TitleTextBlock.FontSize = Math.Clamp(14 * scale * labelFactor, 9, 30); + ModeTextBlock.FontSize = Math.Clamp(12 * scale * labelFactor, 8, 22); + CurrentLabelTextBlock.FontSize = Math.Clamp(12 * scale * labelFactor, 8, 22); + CurrentScoreTextBlock.FontSize = Math.Clamp(76 * scale * headlineFactor, 22, 190); - AverageLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 16); - MinimumLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 16); - MaximumLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 16); - AverageValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 38); - MinimumValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 38); - MaximumValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 38); + AverageLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 20); + MinimumLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 20); + MaximumLabelTextBlock.FontSize = Math.Clamp(11 * scale * labelFactor, 8, 20); + AverageValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 64); + MinimumValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 64); + MaximumValueTextBlock.FontSize = Math.Clamp(22 * scale * statFactor, 11, 64); ModeBadgeBorder.Padding = new Thickness( Math.Clamp(8 * scale * compactMultiplier, 4, 12), Math.Clamp(3 * scale * compactMultiplier, 1.6, 6)); ModeBadgeBorder.CornerRadius = new CornerRadius(Math.Clamp(8 * scale, 5, 14)); + var cardPadding = new Thickness( + Math.Clamp(10 * scale * compactMultiplier * expandedMultiplier, 6, 20), + Math.Clamp(8 * scale * compactMultiplier * expandedMultiplier, 4, 16)); + var cardCornerRadius = new CornerRadius(Math.Clamp(10 * scale, 6, 18)); + AverageCardBorder.Padding = cardPadding; + MinimumCardBorder.Padding = cardPadding; + MaximumCardBorder.Padding = cardPadding; + AverageCardBorder.CornerRadius = cardCornerRadius; + MinimumCardBorder.CornerRadius = cardCornerRadius; + MaximumCardBorder.CornerRadius = cardCornerRadius; + + SummaryGrid.Margin = new Thickness( + 0, + _isUltraCompactMode ? 0 : _isExpandedMode ? Math.Clamp(8 * scale, 4, 18) : Math.Clamp(3 * scale, 1, 8), + 0, + 0); + CurrentScoreTextBlock.Margin = new Thickness( + 0, + _isUltraCompactMode ? 0 : Math.Clamp(2 * scale, 1, 5), + 0, + _isExpandedMode ? Math.Clamp(8 * scale, 4, 16) : Math.Clamp(4 * scale, 2, 8)); + TitleTextBlock.IsVisible = !_isUltraCompactMode; CurrentLabelTextBlock.IsVisible = !_isUltraCompactMode; AverageLabelTextBlock.IsVisible = !_isUltraCompactMode; @@ -444,6 +491,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi var samples = BuildPanelBackgroundSamples(panelColor); var primary = CreateAdaptiveBrush(samples, ValueColorCandidates, minContrast: 4.5); var secondary = CreateAdaptiveBrush(samples, SecondaryColorCandidates, minContrast: 4.5); + var panelLuminance = RelativeLuminance(ToOpaqueAgainst(panelColor, DarkSubstrate)); + var cardBackground = panelLuminance > 0.58 + ? Color.FromArgb(0x42, 0x00, 0x00, 0x00) + : Color.FromArgb(0x2A, 0xFF, 0xFF, 0xFF); + var cardBorder = panelLuminance > 0.58 + ? Color.FromArgb(0x52, 0xFF, 0xFF, 0xFF) + : Color.FromArgb(0x34, 0xFF, 0xFF, 0xFF); TitleTextBlock.Foreground = secondary; CurrentLabelTextBlock.Foreground = secondary; @@ -455,6 +509,13 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi AverageValueTextBlock.Foreground = primary; MinimumValueTextBlock.Foreground = primary; MaximumValueTextBlock.Foreground = primary; + + AverageCardBorder.Background = new SolidColorBrush(cardBackground); + MinimumCardBorder.Background = new SolidColorBrush(cardBackground); + MaximumCardBorder.Background = new SolidColorBrush(cardBackground); + AverageCardBorder.BorderBrush = new SolidColorBrush(cardBorder); + MinimumCardBorder.BorderBrush = new SolidColorBrush(cardBorder); + MaximumCardBorder.BorderBrush = new SolidColorBrush(cardBorder); } private void ApplyModeBadgeColor(Color panelColor, Color baseColor) @@ -604,18 +665,19 @@ public partial class StudyScoreOverviewWidget : UserControl, IDesktopComponentWi { var weightProgress = Math.Clamp((scale - 0.52) / 1.6, 0, 1); var compactDelta = _isUltraCompactMode ? 40 : _isCompactMode ? 20 : 0; + var expandedDelta = _isExpandedMode ? 18 : 0; TitleTextBlock.FontWeight = ToVariableWeight(Lerp(560, 680, weightProgress)); ModeTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, weightProgress)); CurrentLabelTextBlock.FontWeight = ToVariableWeight(Lerp(520, 640, weightProgress)); - CurrentScoreTextBlock.FontWeight = ToVariableWeight(Lerp(640 + compactDelta, 820, weightProgress)); + CurrentScoreTextBlock.FontWeight = ToVariableWeight(Lerp(640 + compactDelta + expandedDelta, 830, weightProgress)); AverageLabelTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); MinimumLabelTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); MaximumLabelTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, weightProgress)); - AverageValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress)); - MinimumValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress)); - MaximumValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta, 760, weightProgress)); + AverageValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta + expandedDelta, 780, weightProgress)); + MinimumValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta + expandedDelta, 780, weightProgress)); + MaximumValueTextBlock.FontWeight = ToVariableWeight(Lerp(620 + compactDelta + expandedDelta, 780, weightProgress)); } private static double Lerp(double from, double to, double ratio)