增加了自习系列组件
This commit is contained in:
lincube
2026-03-04 20:03:14 +08:00
parent 00a3c6a572
commit 40ddcd399d
20 changed files with 2932 additions and 79 deletions

View File

@@ -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";

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "填充",

View File

@@ -42,7 +42,7 @@ public interface IAudioRecorderService : IDisposable
public static class AudioRecorderServiceFactory
{
private static readonly Lazy<IAudioRecorderService> SharedService = new(
private static readonly Lazy<IAudioRecorderService> SharedRecorderService = new(
() =>
{
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
@@ -54,9 +54,31 @@ public static class AudioRecorderServiceFactory
},
isThreadSafe: true);
private static readonly Lazy<IAudioRecorderService> 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();
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Threading;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public static class StudyAnalyticsMonitoringLeaseCoordinatorFactory
{
private static readonly Lazy<StudyAnalyticsMonitoringLeaseCoordinator> 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();
}
}
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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<Border> _waveBars = [];

View File

@@ -0,0 +1,152 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="TitleTextBlock"
Text="Deduction Reasons"
FontSize="13"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Border x:Name="ModeBadgeBorder"
Grid.Column="1"
Padding="8,3"
CornerRadius="8"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"
VerticalAlignment="Center">
<TextBlock x:Name="ModeTextBlock"
Text="Realtime"
FontSize="11"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
<StackPanel x:Name="ReasonsListPanel"
Grid.Row="1"
Spacing="6">
<Border x:Name="SustainedRowBorder"
CornerRadius="10"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="SustainedReasonTextBlock"
Text="Sustained"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock x:Name="SustainedMetricTextBlock"
Grid.Column="1"
Text="p50 -- dBFS"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock x:Name="SustainedLossTextBlock"
Grid.Column="2"
Text="--"
FontSize="19"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="TimeRowBorder"
CornerRadius="10"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="TimeReasonTextBlock"
Text="Duration"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock x:Name="TimeMetricTextBlock"
Grid.Column="1"
Text="over --%"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock x:Name="TimeLossTextBlock"
Grid.Column="2"
Text="--"
FontSize="19"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="SegmentRowBorder"
CornerRadius="10"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="SegmentReasonTextBlock"
Text="Interruptions"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<TextBlock x:Name="SegmentMetricTextBlock"
Grid.Column="1"
Text="-- / min"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock x:Name="SegmentLossTextBlock"
Grid.Column="2"
Text="--"
FontSize="19"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</Grid>
</Border>
</StackPanel>
<Grid Grid.Row="2"
ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<TextBlock x:Name="TotalLossTextBlock"
Text="Total --"
FontSize="12"
FontWeight="SemiBold" />
<TextBlock x:Name="ScoreTextBlock"
Grid.Column="1"
HorizontalAlignment="Right"
Text="Score --"
FontSize="12"
FontWeight="SemiBold" />
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -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<Color> 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<Color> backgroundSamples,
IReadOnlyList<Color> 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<Color> 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);
}
}

View File

@@ -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();

View File

@@ -0,0 +1,136 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
CornerRadius="22"
Padding="14,10"
ClipToBounds="True">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="TitleTextBlock"
Text="Interrupt Density"
FontSize="13"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Border x:Name="ModeBadgeBorder"
Grid.Column="1"
Padding="8,3"
CornerRadius="8"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"
VerticalAlignment="Center">
<TextBlock x:Name="ModeTextBlock"
Text="Realtime"
FontSize="11"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
<Grid x:Name="MainGrid"
Grid.Row="1"
ColumnDefinitions="2*,*"
ColumnSpacing="10">
<StackPanel x:Name="DensityStackPanel"
Spacing="2"
VerticalAlignment="Center">
<StackPanel x:Name="DensityValueStack"
Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Bottom">
<TextBlock x:Name="DensityValueTextBlock"
Text="--"
FontSize="56"
FontWeight="Bold"
VerticalAlignment="Bottom" />
<TextBlock x:Name="DensityUnitTextBlock"
Text="/min"
FontSize="15"
VerticalAlignment="Bottom"
Margin="0,0,0,6" />
</StackPanel>
<TextBlock x:Name="DensityLevelTextBlock"
Text="Level --"
FontSize="13"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<StackPanel x:Name="StatsPanel"
Grid.Column="1"
Spacing="6"
VerticalAlignment="Center">
<Border x:Name="CountCardBorder"
CornerRadius="10"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
Padding="10,6">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="2">
<TextBlock x:Name="CountLabelTextBlock"
Text="Count"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="CountValueTextBlock"
Grid.Row="1"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<Border x:Name="DurationCardBorder"
CornerRadius="10"
Background="#2CFFFFFF"
BorderBrush="#33FFFFFF"
BorderThickness="1"
Padding="10,6">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="2">
<TextBlock x:Name="DurationLabelTextBlock"
Text="Duration"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="DurationValueTextBlock"
Grid.Row="1"
Text="--"
FontSize="20"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
</StackPanel>
</Grid>
<TextBlock x:Name="ThresholdTextBlock"
Grid.Row="2"
Text="Threshold --"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
</UserControl>

View File

@@ -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<Color> 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<Color> backgroundSamples,
IReadOnlyList<Color> 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<Color> 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);
}
}

View File

@@ -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();

View File

@@ -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<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private double _baselineDb = 45;
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
{
_points = points ?? Array.Empty<NoiseRealtimePoint>();
_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
}

View File

@@ -0,0 +1,110 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="using:LanMountainDesktop.Views.Components"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">
<Grid x:Name="ContentRootGrid"
RowDefinitions="Auto,*"
RowSpacing="8">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="TitleTextBlock"
Text="Noise Level Distribution"
FontSize="14"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock x:Name="SummaryTextBlock"
Grid.Column="1"
HorizontalAlignment="Right"
FontSize="12"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<Border x:Name="ModeBadgeBorder"
Grid.Column="2"
Padding="8,3"
CornerRadius="8"
BorderThickness="1"
BorderBrush="#88FFFFFF"
Background="#553B82F6"
VerticalAlignment="Center">
<TextBlock x:Name="ModeTextBlock"
Text="Realtime"
FontSize="12"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</Border>
</Grid>
<Grid Grid.Row="1"
RowDefinitions="*,Auto"
ColumnDefinitions="Auto,*"
RowSpacing="4"
ColumnSpacing="6">
<Grid Grid.Row="0"
Grid.Column="0"
RowDefinitions="Auto,*,*,Auto"
VerticalAlignment="Stretch">
<TextBlock x:Name="YExtremeTextBlock"
Grid.Row="0"
Text="Extreme"
FontSize="10" />
<TextBlock x:Name="YNoisyTextBlock"
Grid.Row="1"
Text="Noisy"
VerticalAlignment="Center"
FontSize="10" />
<TextBlock x:Name="YNormalTextBlock"
Grid.Row="2"
Text="Normal"
VerticalAlignment="Center"
FontSize="10" />
<TextBlock x:Name="YQuietTextBlock"
Grid.Row="3"
Text="Quiet"
FontSize="10" />
</Grid>
<local:StudyNoiseDistributionScatterChartControl x:Name="ChartControl"
Grid.Row="0"
Grid.Column="1"
MinHeight="64" />
<Grid Grid.Row="1"
Grid.Column="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="XLeftTextBlock"
Text="-12s"
FontSize="10" />
<TextBlock x:Name="XCenterTextBlock"
Grid.Column="1"
HorizontalAlignment="Center"
Text="-6s"
FontSize="10" />
<TextBlock x:Name="XRightTextBlock"
Grid.Column="2"
HorizontalAlignment="Right"
Text="Now"
FontSize="10" />
</Grid>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -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<NoiseRealtimePoint> 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<Color> 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<Color> backgroundSamples,
IReadOnlyList<Color> 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<Color> 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);
}
}

View File

@@ -61,65 +61,87 @@
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,2,0,4"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Grid Grid.Row="3"
x:Name="SummaryGrid"
ColumnDefinitions="*,*,*"
ColumnSpacing="10">
<StackPanel x:Name="AverageStack"
Spacing="2">
<TextBlock x:Name="AverageLabelTextBlock"
Text="Average"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="AverageValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
<Border x:Name="AverageCardBorder"
CornerRadius="12"
Background="#24FFFFFF"
BorderBrush="#2EFFFFFF"
BorderThickness="1"
Padding="10,8">
<StackPanel x:Name="AverageStack"
Spacing="2">
<TextBlock x:Name="AverageLabelTextBlock"
Text="Average"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="AverageValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
</Border>
<StackPanel x:Name="MinimumStack"
Grid.Column="1"
Spacing="2">
<TextBlock x:Name="MinimumLabelTextBlock"
Text="Minimum"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="MinimumValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
<Border x:Name="MinimumCardBorder"
Grid.Column="1"
CornerRadius="12"
Background="#24FFFFFF"
BorderBrush="#2EFFFFFF"
BorderThickness="1"
Padding="10,8">
<StackPanel x:Name="MinimumStack"
Spacing="2">
<TextBlock x:Name="MinimumLabelTextBlock"
Text="Minimum"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="MinimumValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
</Border>
<StackPanel x:Name="MaximumStack"
Grid.Column="2"
Spacing="2">
<TextBlock x:Name="MaximumLabelTextBlock"
Text="Maximum"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="MaximumValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
<Border x:Name="MaximumCardBorder"
Grid.Column="2"
CornerRadius="12"
Background="#24FFFFFF"
BorderBrush="#2EFFFFFF"
BorderThickness="1"
Padding="10,8">
<StackPanel x:Name="MaximumStack"
Spacing="2">
<TextBlock x:Name="MaximumLabelTextBlock"
Text="Maximum"
FontSize="11"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock x:Name="MaximumValueTextBlock"
Text="--"
FontSize="22"
FontWeight="SemiBold"
MaxLines="1"
TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>

View File

@@ -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)