mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
1711 lines
62 KiB
C#
1711 lines
62 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using LanMountainDesktop.DesktopComponents.Runtime;
|
|
using LanMountainDesktop.ComponentSystem;
|
|
using LanMountainDesktop.Host.Abstractions;
|
|
using LanMountainDesktop.Models;
|
|
using LanMountainDesktop.Services;
|
|
using LanMountainDesktop.Theme;
|
|
|
|
namespace LanMountainDesktop.Views.Components;
|
|
|
|
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentChromeContextAware
|
|
{
|
|
private enum WeatherVisualKind
|
|
{
|
|
Unknown,
|
|
ClearDay,
|
|
ClearNight,
|
|
PartlyCloudyDay,
|
|
PartlyCloudyNight,
|
|
CloudyDay,
|
|
CloudyNight,
|
|
Haze,
|
|
Sleet,
|
|
RainLight,
|
|
RainHeavy,
|
|
Storm,
|
|
Snow,
|
|
Fog
|
|
}
|
|
|
|
private readonly record struct WeatherVisualPalette(
|
|
string GradientFrom,
|
|
string GradientTo,
|
|
string Tint,
|
|
string PrimaryText,
|
|
string SecondaryText,
|
|
string TertiaryText,
|
|
string ParticleColor);
|
|
|
|
private readonly record struct WeatherMotionProfile(
|
|
double DriftX,
|
|
double DriftY,
|
|
double ZoomBase,
|
|
double ZoomAmplitude,
|
|
double MotionOpacityBase,
|
|
double MotionOpacityPulse,
|
|
double LightOpacityBase,
|
|
double LightOpacityPulse,
|
|
double ShadeOpacityBase,
|
|
double ShadeOpacityPulse,
|
|
double PhaseStep,
|
|
int ParticleCount,
|
|
double ParticleSpeedMin,
|
|
double ParticleSpeedMax,
|
|
double ParticleLengthMin,
|
|
double ParticleLengthMax,
|
|
double ParticleDriftPerTick);
|
|
|
|
private sealed class ParticleState
|
|
{
|
|
public double Speed { get; set; }
|
|
|
|
public double Drift { get; set; }
|
|
}
|
|
|
|
private sealed record MultiDayWeatherWidgetConfig(
|
|
string LanguageCode,
|
|
string Locale,
|
|
string LocationKey,
|
|
string LocationName,
|
|
double Latitude,
|
|
double Longitude);
|
|
|
|
private readonly record struct HourlyForecastItem(
|
|
DateTime Time,
|
|
string TimeLabel,
|
|
HyperOS3WeatherVisualKind IconKind,
|
|
string TemperatureText);
|
|
|
|
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
|
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
|
|
|
private readonly DispatcherTimer _refreshTimer = new()
|
|
{
|
|
Interval = TimeSpan.FromMinutes(12)
|
|
};
|
|
|
|
private readonly DispatcherTimer _backgroundAnimationTimer = new()
|
|
{
|
|
Interval = FluttermotionToken.WeatherAnimationFrameInterval
|
|
};
|
|
|
|
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
|
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
|
private readonly LocalizationService _localizationService = new();
|
|
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
|
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
|
private readonly List<Border> _particleVisuals = new();
|
|
private readonly List<ParticleState> _particleStates = new();
|
|
private readonly Random _particleRandom = new();
|
|
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
|
|
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
|
|
|
|
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
|
private TimeZoneService? _timeZoneService;
|
|
private CancellationTokenSource? _refreshCts;
|
|
private WeatherSnapshot? _latestSnapshot;
|
|
private string _languageCode = "zh-CN";
|
|
private double _currentCellSize = 48;
|
|
private ComponentChromeContext? _chromeContext;
|
|
private WeatherVisualKind _activeVisualKind = WeatherVisualKind.ClearDay;
|
|
private double _animationPhase;
|
|
private int _activeParticleCount;
|
|
private bool _isAttached;
|
|
private bool _isOnActivePage = true;
|
|
private bool _isRefreshing;
|
|
private bool _autoRefreshEnabled = true;
|
|
private string _componentId = BuiltInComponentIds.DesktopMultiDayWeather;
|
|
private string _placementId = string.Empty;
|
|
private readonly TextBlock[] _hourlyTimeBlocks;
|
|
private readonly Image[] _hourlyIconBlocks;
|
|
private readonly TextBlock[] _hourlyTempBlocks;
|
|
|
|
public MultiDayWeatherWidget()
|
|
{
|
|
InitializeComponent();
|
|
InitializeMotionTransform();
|
|
_hourlyTimeBlocks =
|
|
[
|
|
HourlyTime0, HourlyTime1, HourlyTime2, HourlyTime3, HourlyTime4
|
|
];
|
|
_hourlyIconBlocks =
|
|
[
|
|
HourlyIcon0, HourlyIcon1, HourlyIcon2, HourlyIcon3, HourlyIcon4
|
|
];
|
|
_hourlyTempBlocks =
|
|
[
|
|
HourlyTemp0, HourlyTemp1, HourlyTemp2, HourlyTemp3, HourlyTemp4
|
|
];
|
|
ConfigureTextOverflowGuards();
|
|
|
|
_refreshTimer.Tick += OnRefreshTimerTick;
|
|
_backgroundAnimationTimer.Tick += OnBackgroundAnimationTick;
|
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
SizeChanged += OnSizeChanged;
|
|
|
|
InitializeParticleVisuals();
|
|
ApplyVisualTheme(WeatherVisualKind.ClearDay);
|
|
ApplyNotConfiguredState();
|
|
ApplyCellSize(_currentCellSize);
|
|
ApplyAutoRefreshSettings();
|
|
}
|
|
|
|
private void ConfigureTextOverflowGuards()
|
|
{
|
|
CityTextBlock.TextWrapping = TextWrapping.NoWrap;
|
|
CityTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
|
CityTextBlock.MaxLines = 1;
|
|
|
|
ConditionTextBlock.TextWrapping = TextWrapping.NoWrap;
|
|
ConditionTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
|
ConditionTextBlock.MaxLines = 1;
|
|
|
|
RangeTextBlock.TextWrapping = TextWrapping.NoWrap;
|
|
RangeTextBlock.TextTrimming = TextTrimming.CharacterEllipsis;
|
|
RangeTextBlock.MaxLines = 1;
|
|
|
|
TemperatureTextBlock.TextWrapping = TextWrapping.NoWrap;
|
|
TemperatureTextBlock.TextTrimming = TextTrimming.None;
|
|
TemperatureTextBlock.MaxLines = 1;
|
|
|
|
foreach (var timeBlock in _hourlyTimeBlocks)
|
|
{
|
|
timeBlock.TextWrapping = TextWrapping.NoWrap;
|
|
timeBlock.TextTrimming = TextTrimming.None;
|
|
timeBlock.MaxLines = 1;
|
|
timeBlock.TextAlignment = TextAlignment.Center;
|
|
}
|
|
|
|
foreach (var tempBlock in _hourlyTempBlocks)
|
|
{
|
|
tempBlock.TextWrapping = TextWrapping.NoWrap;
|
|
tempBlock.TextTrimming = TextTrimming.None;
|
|
tempBlock.MaxLines = 1;
|
|
tempBlock.TextAlignment = TextAlignment.Center;
|
|
}
|
|
}
|
|
|
|
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
|
{
|
|
ClearTimeZoneService();
|
|
_timeZoneService = timeZoneService;
|
|
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
|
}
|
|
|
|
public void ClearTimeZoneService()
|
|
{
|
|
if (_timeZoneService is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
|
_timeZoneService = null;
|
|
}
|
|
|
|
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
|
|
{
|
|
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
|
|
if (_isAttached && _isOnActivePage)
|
|
{
|
|
_ = RefreshWeatherAsync(forceRefresh: false);
|
|
}
|
|
}
|
|
|
|
public void RefreshFromSettings()
|
|
{
|
|
ApplyAutoRefreshSettings();
|
|
if (_isAttached && _isOnActivePage)
|
|
{
|
|
_ = RefreshWeatherAsync(forceRefresh: true);
|
|
}
|
|
}
|
|
|
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
|
{
|
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
|
? BuiltInComponentIds.DesktopMultiDayWeather
|
|
: componentId.Trim();
|
|
_placementId = placementId?.Trim() ?? string.Empty;
|
|
RefreshFromSettings();
|
|
}
|
|
|
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
|
{
|
|
_ = isEditMode;
|
|
var wasOnActivePage = _isOnActivePage;
|
|
_isOnActivePage = isOnActivePage;
|
|
UpdateTimerState();
|
|
|
|
if (!wasOnActivePage && _isOnActivePage && _isAttached)
|
|
{
|
|
_ = RefreshWeatherAsync(forceRefresh: false);
|
|
}
|
|
}
|
|
|
|
public void SetComponentChromeContext(ComponentChromeContext context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
_chromeContext = context;
|
|
ApplyCellSize(_currentCellSize);
|
|
}
|
|
|
|
public void ApplyCellSize(double cellSize)
|
|
{
|
|
_currentCellSize = Math.Max(1, cellSize);
|
|
var metrics = HyperOS3WeatherTheme.ResolveMetrics(HyperOS3WeatherWidgetKind.MultiDay4x2);
|
|
var scale = ResolveScale();
|
|
var hostWidth = Bounds.Width > 1 ? Bounds.Width : Math.Max(140, _currentCellSize * 4);
|
|
var hostHeight = Bounds.Height > 1 ? Bounds.Height : Math.Max(78, _currentCellSize * 2);
|
|
var cornerRadius = ComponentChromeCornerRadiusHelper.Scale(
|
|
_currentCellSize * metrics.CornerRadiusScale,
|
|
24,
|
|
46,
|
|
_chromeContext);
|
|
|
|
ComponentChromeCornerRadiusHelper.Apply(
|
|
cornerRadius,
|
|
RootBorder,
|
|
BackgroundImageLayer,
|
|
BackgroundMotionLayer,
|
|
BackgroundTintLayer,
|
|
BackgroundLightLayer,
|
|
BackgroundShadeLayer);
|
|
ContentPaddingBorder.Padding = new Thickness(
|
|
ComponentChromeCornerRadiusHelper.SafeValue(Math.Min((_currentCellSize * metrics.HorizontalPaddingScale) * scale, hostWidth * 0.034), 4, 22, _chromeContext),
|
|
ComponentChromeCornerRadiusHelper.SafeValue(Math.Min((_currentCellSize * metrics.VerticalPaddingScale) * scale, hostHeight * 0.068), 3, 18, _chromeContext));
|
|
ApplyAdaptiveTypography();
|
|
ResetParticles();
|
|
}
|
|
|
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
_isAttached = true;
|
|
ApplyAutoRefreshSettings();
|
|
UpdateTimerState();
|
|
if (_isOnActivePage)
|
|
{
|
|
_ = RefreshWeatherAsync(forceRefresh: false);
|
|
}
|
|
}
|
|
|
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
_isAttached = false;
|
|
UpdateTimerState();
|
|
CancelRefreshRequest();
|
|
}
|
|
|
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
{
|
|
ApplyCellSize(_currentCellSize);
|
|
ResetParticles();
|
|
}
|
|
|
|
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
|
{
|
|
await RefreshWeatherAsync(forceRefresh: false);
|
|
}
|
|
|
|
private void OnBackgroundAnimationTick(object? sender, EventArgs e)
|
|
{
|
|
if (!_isAttached || !_isOnActivePage)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var motion = ResolveMotionProfile(_activeVisualKind);
|
|
_animationPhase += motion.PhaseStep;
|
|
if (_animationPhase > Math.PI * 2)
|
|
{
|
|
_animationPhase -= Math.PI * 2;
|
|
}
|
|
|
|
var sin = Math.Sin(_animationPhase);
|
|
var cos = Math.Cos(_animationPhase * 0.83);
|
|
var zoom = motion.ZoomBase + (sin * motion.ZoomAmplitude);
|
|
|
|
SetMotionTransform(sin * motion.DriftX, cos * motion.DriftY, zoom);
|
|
|
|
BackgroundMotionLayer.Opacity = Math.Clamp(
|
|
motion.MotionOpacityBase + (cos * motion.MotionOpacityPulse),
|
|
0.08,
|
|
0.92);
|
|
BackgroundLightLayer.Opacity = Math.Clamp(
|
|
motion.LightOpacityBase + (sin * motion.LightOpacityPulse),
|
|
0.10,
|
|
0.95);
|
|
BackgroundShadeLayer.Opacity = Math.Clamp(
|
|
motion.ShadeOpacityBase + (cos * motion.ShadeOpacityPulse),
|
|
0.42,
|
|
0.95);
|
|
|
|
AdvanceParticles(motion);
|
|
}
|
|
|
|
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
|
{
|
|
if (_isAttached)
|
|
{
|
|
_ = RefreshWeatherAsync(forceRefresh: false);
|
|
}
|
|
}
|
|
|
|
private WeatherVisualKind ResolveFallbackVisualKind()
|
|
{
|
|
return IsNightNow() ? WeatherVisualKind.ClearNight : WeatherVisualKind.ClearDay;
|
|
}
|
|
|
|
private bool ResolveIsNight(WeatherSnapshot snapshot)
|
|
{
|
|
return HyperOS3WeatherTheme.ResolveIsNightPreferred(
|
|
snapshot,
|
|
_timeZoneService?.CurrentTimeZone,
|
|
_timeZoneService?.GetCurrentTime() ?? DateTime.Now);
|
|
}
|
|
|
|
private bool IsNightNow()
|
|
{
|
|
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
|
return now.Hour < 6 || now.Hour >= 18;
|
|
}
|
|
|
|
private async Task RefreshWeatherAsync(bool forceRefresh)
|
|
{
|
|
if (!_isAttached || !_isOnActivePage || _isRefreshing)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isRefreshing = true;
|
|
var config = LoadConfig();
|
|
_languageCode = config.LanguageCode;
|
|
|
|
if (string.IsNullOrWhiteSpace(config.LocationKey))
|
|
{
|
|
ApplyNotConfiguredState();
|
|
_isRefreshing = false;
|
|
return;
|
|
}
|
|
|
|
if (_latestSnapshot is null)
|
|
{
|
|
ApplyLoadingState(config.LocationName);
|
|
}
|
|
|
|
var cts = new CancellationTokenSource();
|
|
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
|
previous?.Cancel();
|
|
previous?.Dispose();
|
|
|
|
try
|
|
{
|
|
var query = new WeatherQuery(
|
|
LocationKey: config.LocationKey,
|
|
Latitude: config.Latitude,
|
|
Longitude: config.Longitude,
|
|
ForecastDays: 3,
|
|
Locale: config.Locale,
|
|
ForceRefresh: forceRefresh);
|
|
|
|
var result = await _weatherInfoService.GetWeatherAsync(query, cts.Token);
|
|
if (cts.IsCancellationRequested || !_isAttached)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!result.Success || result.Data is null)
|
|
{
|
|
ApplyFailedState(config.LocationName);
|
|
return;
|
|
}
|
|
|
|
_latestSnapshot = result.Data;
|
|
ApplySnapshot(result.Data, config.LocationName);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Ignore canceled refresh requests.
|
|
}
|
|
catch
|
|
{
|
|
if (!cts.IsCancellationRequested && _isAttached)
|
|
{
|
|
ApplyFailedState(config.LocationName);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (ReferenceEquals(_refreshCts, cts))
|
|
{
|
|
_refreshCts = null;
|
|
}
|
|
|
|
cts.Dispose();
|
|
_isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
private MultiDayWeatherWidgetConfig LoadConfig()
|
|
{
|
|
var snapshot = _settingsService.Load();
|
|
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
|
var locale = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
|
? "zh_cn"
|
|
: "en_us";
|
|
|
|
var latitude = NormalizeLatitude(snapshot.WeatherLatitude);
|
|
var longitude = NormalizeLongitude(snapshot.WeatherLongitude);
|
|
var modeIsCoordinates = string.Equals(snapshot.WeatherLocationMode, "Coordinates", StringComparison.OrdinalIgnoreCase);
|
|
var locationKey = snapshot.WeatherLocationKey?.Trim() ?? string.Empty;
|
|
var locationName = snapshot.WeatherLocationName?.Trim() ?? string.Empty;
|
|
|
|
if (modeIsCoordinates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(locationKey))
|
|
{
|
|
locationKey = BuildCoordinateLocationKey(latitude, longitude);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(locationName))
|
|
{
|
|
locationName = BuildCoordinateLocationName(latitude, longitude, languageCode);
|
|
}
|
|
}
|
|
else if (string.IsNullOrWhiteSpace(locationName))
|
|
{
|
|
locationName = locationKey;
|
|
}
|
|
|
|
return new MultiDayWeatherWidgetConfig(
|
|
languageCode,
|
|
locale,
|
|
locationKey,
|
|
locationName,
|
|
latitude,
|
|
longitude);
|
|
}
|
|
|
|
private void ApplySnapshot(WeatherSnapshot snapshot, string fallbackLocationName)
|
|
{
|
|
var isNight = ResolveIsNight(snapshot);
|
|
var visual = XiaomiWeatherVisualResolver.Resolve(
|
|
snapshot.Current.WeatherText,
|
|
snapshot.Current.WeatherCode,
|
|
isNight,
|
|
_languageCode);
|
|
var visualKind = ResolveVisualKind(visual.VisualKind);
|
|
ApplyVisualTheme(visualKind);
|
|
|
|
var rawLocation = string.IsNullOrWhiteSpace(snapshot.LocationName)
|
|
? fallbackLocationName
|
|
: snapshot.LocationName;
|
|
CityTextBlock.Text = ResolvePreciseDisplayLocation(rawLocation, _languageCode, L("weather.widget.location_unknown", "Unknown location"));
|
|
|
|
ConditionTextBlock.Text = visual.DisplayText;
|
|
SetMainWeatherIcon(visual.PrimaryIconAsset, visualKind);
|
|
SetLoadingSkeleton(false);
|
|
|
|
TemperatureTextBlock.Text = FormatTemperature(snapshot.Current.TemperatureC);
|
|
var (low, high) = ResolveTemperatureRange(snapshot);
|
|
RangeTextBlock.Text = FormatTemperatureRange(low, high);
|
|
ApplyHourlyForecastItems(BuildHourlyForecastItems(snapshot));
|
|
ApplyAdaptiveTypography();
|
|
}
|
|
|
|
private void ApplyNotConfiguredState()
|
|
{
|
|
var fallbackKind = ResolveFallbackVisualKind();
|
|
ApplyVisualTheme(fallbackKind);
|
|
SetMainWeatherIcon(null, fallbackKind);
|
|
SetLoadingSkeleton(false);
|
|
CityTextBlock.Text = L("weather.widget.location_not_configured", "Weather location is not configured");
|
|
ConditionTextBlock.Text = L("weather.widget.condition_unknown", "Unknown");
|
|
TemperatureTextBlock.Text = "--°";
|
|
RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --");
|
|
ApplyHourlyForecastItems(BuildPlaceholderHourlyForecastItems(fallbackKind));
|
|
ApplyAdaptiveTypography();
|
|
_latestSnapshot = null;
|
|
}
|
|
|
|
private void ApplyLoadingState(string locationName)
|
|
{
|
|
var loadingKind = IsNightNow() ? WeatherVisualKind.CloudyNight : WeatherVisualKind.CloudyDay;
|
|
ApplyVisualTheme(loadingKind);
|
|
SetMainWeatherIcon(null, loadingKind);
|
|
SetLoadingSkeleton(true);
|
|
CityTextBlock.Text = ResolvePreciseDisplayLocation(
|
|
locationName,
|
|
_languageCode,
|
|
L("weather.widget.location_unknown", "Unknown location"));
|
|
ConditionTextBlock.Text = L("weather.widget.loading", "Loading...");
|
|
TemperatureTextBlock.Text = "--°";
|
|
RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --");
|
|
ApplyHourlyForecastItems(BuildPlaceholderHourlyForecastItems(loadingKind));
|
|
ApplyAdaptiveTypography();
|
|
}
|
|
|
|
private void ApplyFailedState(string locationName)
|
|
{
|
|
ApplyVisualTheme(WeatherVisualKind.Unknown);
|
|
SetMainWeatherIcon(HyperOS3WeatherTheme.ResolveHeroIconAsset(HyperOS3WeatherVisualKind.Unknown), WeatherVisualKind.Unknown);
|
|
SetLoadingSkeleton(false);
|
|
CityTextBlock.Text = ResolvePreciseDisplayLocation(
|
|
locationName,
|
|
_languageCode,
|
|
L("weather.widget.location_unknown", "Unknown location"));
|
|
ConditionTextBlock.Text = L("weather.widget.fetch_failed", "Weather fetch failed");
|
|
TemperatureTextBlock.Text = "--°";
|
|
RangeTextBlock.Text = L("weather.widget.range_unknown", "-- / --");
|
|
ApplyHourlyForecastItems(BuildPlaceholderHourlyForecastItems(WeatherVisualKind.Unknown));
|
|
ApplyAdaptiveTypography();
|
|
_latestSnapshot = null;
|
|
}
|
|
|
|
private void ApplyVisualTheme(WeatherVisualKind kind)
|
|
{
|
|
_activeVisualKind = kind;
|
|
var palette = ResolvePalette(kind);
|
|
RootBorder.Background = CreateGradientBrush(palette.GradientFrom, palette.GradientTo);
|
|
BackgroundImageLayer.Background = ResolveWeatherBackgroundBrush(kind, palette);
|
|
BackgroundMotionLayer.Background = ResolveWeatherBackgroundBrush(kind, palette);
|
|
BackgroundTintLayer.Background = CreateSolidBrush(palette.Tint);
|
|
|
|
var particleBrush = ResolveParticleBrush(ToThemeKind(kind), palette.ParticleColor);
|
|
var isNightVisual = kind is WeatherVisualKind.ClearNight or WeatherVisualKind.PartlyCloudyNight or WeatherVisualKind.CloudyNight;
|
|
var backgroundSamples = WeatherTypographyAccessibility.BuildBackgroundSamples(
|
|
palette.GradientFrom,
|
|
palette.GradientTo,
|
|
palette.Tint,
|
|
isNightVisual);
|
|
var primary = WeatherTypographyAccessibility.CreateReadableBrush(
|
|
palette.PrimaryText,
|
|
backgroundSamples,
|
|
WeatherTypographyAccessibility.WcagLargeTextContrast);
|
|
var cityBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
|
palette.SecondaryText,
|
|
backgroundSamples,
|
|
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
|
isNightVisual ? (byte)0xE6 : (byte)0xD4);
|
|
var conditionSecondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
|
palette.PrimaryText,
|
|
backgroundSamples,
|
|
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
|
isNightVisual ? (byte)0xED : (byte)0xDF);
|
|
var rangeSecondary = WeatherTypographyAccessibility.CreateReadableBrush(
|
|
palette.PrimaryText,
|
|
backgroundSamples,
|
|
WeatherTypographyAccessibility.WcagLargeTextContrast,
|
|
isNightVisual ? (byte)0xE2 : (byte)0xCE);
|
|
var forecastTimeBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
|
palette.PrimaryText,
|
|
backgroundSamples,
|
|
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
|
isNightVisual ? (byte)0xE7 : (byte)0xDA);
|
|
var forecastTempBrush = WeatherTypographyAccessibility.CreateReadableBrush(
|
|
palette.TertiaryText,
|
|
backgroundSamples,
|
|
WeatherTypographyAccessibility.WcagNormalTextContrast,
|
|
isNightVisual ? (byte)0xC0 : (byte)0xAC);
|
|
HourlyPanelBorder.Background = Brushes.Transparent;
|
|
LocationIcon.Foreground = cityBrush;
|
|
CityTextBlock.Foreground = cityBrush;
|
|
TemperatureTextBlock.Foreground = primary;
|
|
ConditionTextBlock.Foreground = conditionSecondary;
|
|
RangeTextBlock.Foreground = rangeSecondary;
|
|
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
|
{
|
|
_hourlyTimeBlocks[i].Foreground = forecastTimeBrush;
|
|
_hourlyTempBlocks[i].Foreground = forecastTempBrush;
|
|
}
|
|
|
|
foreach (var particle in _particleVisuals)
|
|
{
|
|
particle.Background = particleBrush;
|
|
}
|
|
|
|
ResetAnimationState();
|
|
ResetParticles();
|
|
}
|
|
|
|
private IBrush ResolveWeatherBackgroundBrush(WeatherVisualKind kind, WeatherVisualPalette palette)
|
|
{
|
|
if (_backgroundBrushCache.TryGetValue(kind, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
var uriText = HyperOS3WeatherTheme.ResolveBackgroundAsset(ToThemeKind(kind));
|
|
if (!string.IsNullOrWhiteSpace(uriText))
|
|
{
|
|
var imageSource = HyperOS3WeatherAssetLoader.LoadImage(uriText);
|
|
if (imageSource is IImageBrushSource brushSource)
|
|
{
|
|
var imageBrush = new ImageBrush
|
|
{
|
|
Source = brushSource,
|
|
Stretch = Stretch.UniformToFill,
|
|
AlignmentX = AlignmentX.Center,
|
|
AlignmentY = AlignmentY.Center
|
|
};
|
|
_backgroundBrushCache[kind] = imageBrush;
|
|
return imageBrush;
|
|
}
|
|
}
|
|
|
|
var gradientBrush = CreateGradientBrush(palette.GradientFrom, palette.GradientTo);
|
|
_backgroundBrushCache[kind] = gradientBrush;
|
|
return gradientBrush;
|
|
}
|
|
|
|
private IBrush ResolveParticleBrush(HyperOS3WeatherVisualKind kind, string fallbackColor)
|
|
{
|
|
if (_particleBrushCache.TryGetValue(kind, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
var uriText = HyperOS3WeatherTheme.ResolveParticleAsset(kind);
|
|
if (!string.IsNullOrWhiteSpace(uriText))
|
|
{
|
|
var imageSource = HyperOS3WeatherAssetLoader.LoadImage(uriText);
|
|
if (imageSource is IImageBrushSource brushSource)
|
|
{
|
|
var imageBrush = new ImageBrush
|
|
{
|
|
Source = brushSource,
|
|
Stretch = Stretch.UniformToFill,
|
|
AlignmentX = AlignmentX.Center,
|
|
AlignmentY = AlignmentY.Center
|
|
};
|
|
_particleBrushCache[kind] = imageBrush;
|
|
return imageBrush;
|
|
}
|
|
}
|
|
|
|
var solidBrush = CreateSolidBrush(fallbackColor);
|
|
_particleBrushCache[kind] = solidBrush;
|
|
return solidBrush;
|
|
}
|
|
|
|
private static WeatherVisualKind ResolveVisualKind(int? weatherCode, bool isNight)
|
|
{
|
|
return ResolveVisualKind(HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, isNight));
|
|
}
|
|
|
|
private static WeatherVisualKind ResolveVisualKind(HyperOS3WeatherVisualKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
HyperOS3WeatherVisualKind.Unknown => WeatherVisualKind.Unknown,
|
|
HyperOS3WeatherVisualKind.ClearDay => WeatherVisualKind.ClearDay,
|
|
HyperOS3WeatherVisualKind.ClearNight => WeatherVisualKind.ClearNight,
|
|
HyperOS3WeatherVisualKind.PartlyCloudyDay => WeatherVisualKind.PartlyCloudyDay,
|
|
HyperOS3WeatherVisualKind.PartlyCloudyNight => WeatherVisualKind.PartlyCloudyNight,
|
|
HyperOS3WeatherVisualKind.CloudyDay => WeatherVisualKind.CloudyDay,
|
|
HyperOS3WeatherVisualKind.CloudyNight => WeatherVisualKind.CloudyNight,
|
|
HyperOS3WeatherVisualKind.Haze => WeatherVisualKind.Haze,
|
|
HyperOS3WeatherVisualKind.Sleet => WeatherVisualKind.Sleet,
|
|
HyperOS3WeatherVisualKind.RainLight => WeatherVisualKind.RainLight,
|
|
HyperOS3WeatherVisualKind.RainHeavy => WeatherVisualKind.RainHeavy,
|
|
HyperOS3WeatherVisualKind.Storm => WeatherVisualKind.Storm,
|
|
HyperOS3WeatherVisualKind.Snow => WeatherVisualKind.Snow,
|
|
HyperOS3WeatherVisualKind.Fog => WeatherVisualKind.Fog,
|
|
_ => WeatherVisualKind.Unknown
|
|
};
|
|
}
|
|
|
|
private static WeatherVisualPalette ResolvePalette(WeatherVisualKind kind)
|
|
{
|
|
var palette = HyperOS3WeatherTheme.ResolvePalette(ToThemeKind(kind));
|
|
return new WeatherVisualPalette(
|
|
palette.GradientFrom,
|
|
palette.GradientTo,
|
|
palette.Tint,
|
|
palette.PrimaryText,
|
|
palette.SecondaryText,
|
|
palette.TertiaryText,
|
|
palette.ParticleColor);
|
|
}
|
|
|
|
private static HyperOS3WeatherVisualKind ToThemeKind(WeatherVisualKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
WeatherVisualKind.Unknown => HyperOS3WeatherVisualKind.Unknown,
|
|
WeatherVisualKind.ClearDay => HyperOS3WeatherVisualKind.ClearDay,
|
|
WeatherVisualKind.ClearNight => HyperOS3WeatherVisualKind.ClearNight,
|
|
WeatherVisualKind.PartlyCloudyDay => HyperOS3WeatherVisualKind.PartlyCloudyDay,
|
|
WeatherVisualKind.PartlyCloudyNight => HyperOS3WeatherVisualKind.PartlyCloudyNight,
|
|
WeatherVisualKind.CloudyDay => HyperOS3WeatherVisualKind.CloudyDay,
|
|
WeatherVisualKind.CloudyNight => HyperOS3WeatherVisualKind.CloudyNight,
|
|
WeatherVisualKind.Haze => HyperOS3WeatherVisualKind.Haze,
|
|
WeatherVisualKind.Sleet => HyperOS3WeatherVisualKind.Sleet,
|
|
WeatherVisualKind.RainLight => HyperOS3WeatherVisualKind.RainLight,
|
|
WeatherVisualKind.RainHeavy => HyperOS3WeatherVisualKind.RainHeavy,
|
|
WeatherVisualKind.Storm => HyperOS3WeatherVisualKind.Storm,
|
|
WeatherVisualKind.Snow => HyperOS3WeatherVisualKind.Snow,
|
|
WeatherVisualKind.Fog => HyperOS3WeatherVisualKind.Fog,
|
|
_ => HyperOS3WeatherVisualKind.Unknown
|
|
};
|
|
}
|
|
|
|
private string ResolveWeatherConditionText(string? weatherText, int? weatherCode, WeatherVisualKind kind)
|
|
{
|
|
_ = kind;
|
|
return XiaomiWeatherVisualResolver.ResolveDisplayText(weatherText, weatherCode, _languageCode);
|
|
}
|
|
|
|
private static (double? Low, double? High) ResolveTemperatureRange(WeatherSnapshot snapshot)
|
|
{
|
|
var first = snapshot.DailyForecasts.FirstOrDefault();
|
|
var low = first?.LowTemperatureC;
|
|
var high = first?.HighTemperatureC;
|
|
|
|
if (!low.HasValue && !high.HasValue && snapshot.Current.TemperatureC.HasValue)
|
|
{
|
|
var baseline = snapshot.Current.TemperatureC.Value;
|
|
low = Math.Floor(baseline - 2);
|
|
high = Math.Ceiling(baseline + 2);
|
|
}
|
|
|
|
return (low, high);
|
|
}
|
|
|
|
private string FormatTemperatureRange(double? low, double? high)
|
|
{
|
|
if (!low.HasValue && !high.HasValue)
|
|
{
|
|
return L("weather.widget.range_unknown", "--/--");
|
|
}
|
|
|
|
var lowText = FormatTemperature(low);
|
|
var highText = FormatTemperature(high);
|
|
return string.Format(
|
|
GetUiCulture(),
|
|
L("weather.widget.range_format", "{0}/{1}"),
|
|
lowText,
|
|
highText);
|
|
}
|
|
|
|
private string FormatAirQualityText(int? airQualityIndex)
|
|
{
|
|
if (!airQualityIndex.HasValue || airQualityIndex.Value <= 0)
|
|
{
|
|
return L("weather.multiday.aqi_unknown", "Air --");
|
|
}
|
|
|
|
return string.Format(
|
|
GetUiCulture(),
|
|
L("weather.multiday.aqi_format", "Air Quality {0}"),
|
|
airQualityIndex.Value);
|
|
}
|
|
|
|
private static string FormatTemperature(double? value)
|
|
{
|
|
if (!value.HasValue || double.IsNaN(value.Value) || double.IsInfinity(value.Value))
|
|
{
|
|
return "--°";
|
|
}
|
|
|
|
var rounded = (int)Math.Round(value.Value, MidpointRounding.AwayFromZero);
|
|
return string.Create(CultureInfo.InvariantCulture, $"{rounded}°");
|
|
}
|
|
|
|
private IReadOnlyList<HourlyForecastItem> BuildHourlyForecastItems(WeatherSnapshot snapshot)
|
|
{
|
|
const int itemCount = 5;
|
|
var today = DateOnly.FromDateTime(_timeZoneService?.GetCurrentTime() ?? DateTime.Now);
|
|
var firstFallback = snapshot.DailyForecasts.Skip(1).FirstOrDefault() ?? snapshot.DailyForecasts.FirstOrDefault();
|
|
var items = new List<HourlyForecastItem>(itemCount);
|
|
|
|
for (var i = 0; i < itemCount; i++)
|
|
{
|
|
var date = today.AddDays(i + 1);
|
|
var daily = ResolveDailyForecastForDate(snapshot, date) ?? firstFallback;
|
|
var weatherCode = daily?.DayWeatherCode ??
|
|
daily?.NightWeatherCode ??
|
|
snapshot.Current.WeatherCode;
|
|
var visualKind = ResolveVisualKind(weatherCode, isNight: false);
|
|
var low = daily?.LowTemperatureC;
|
|
var high = daily?.HighTemperatureC;
|
|
var rangeText = string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"{0}/{1}",
|
|
FormatTemperature(low),
|
|
FormatTemperature(high));
|
|
var label = ResolveForecastDayLabel(date, i + 1);
|
|
|
|
items.Add(new HourlyForecastItem(
|
|
date.ToDateTime(TimeOnly.MinValue),
|
|
label,
|
|
ToThemeKind(visualKind),
|
|
rangeText));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private IReadOnlyList<HourlyForecastItem> BuildPlaceholderHourlyForecastItems(WeatherVisualKind visualKind)
|
|
{
|
|
const int itemCount = 5;
|
|
var items = new List<HourlyForecastItem>(itemCount);
|
|
var start = DateOnly.FromDateTime(_timeZoneService?.GetCurrentTime() ?? DateTime.Now);
|
|
var iconKind = ToThemeKind(visualKind);
|
|
for (var i = 0; i < itemCount; i++)
|
|
{
|
|
var date = start.AddDays(i + 1);
|
|
var label = ResolveForecastDayLabel(date, i + 1);
|
|
items.Add(new HourlyForecastItem(
|
|
date.ToDateTime(TimeOnly.MinValue),
|
|
label,
|
|
iconKind,
|
|
"--°/--°"));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private void ApplyHourlyForecastItems(IReadOnlyList<HourlyForecastItem> items)
|
|
{
|
|
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
|
{
|
|
if (i >= items.Count)
|
|
{
|
|
_hourlyTimeBlocks[i].Text = "--";
|
|
_hourlyTempBlocks[i].Text = "--°/--°";
|
|
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(
|
|
HyperOS3WeatherTheme.ResolveMiniIconAsset(ToThemeKind(_activeVisualKind)));
|
|
continue;
|
|
}
|
|
|
|
var item = items[i];
|
|
_hourlyTimeBlocks[i].Text = item.TimeLabel;
|
|
_hourlyIconBlocks[i].Source = HyperOS3WeatherAssetLoader.LoadImage(
|
|
HyperOS3WeatherTheme.ResolveMiniIconAsset(item.IconKind));
|
|
_hourlyTempBlocks[i].Text = item.TemperatureText;
|
|
}
|
|
}
|
|
|
|
private static WeatherDailyForecast? ResolveDailyForecastForDate(WeatherSnapshot snapshot, DateOnly date)
|
|
{
|
|
foreach (var forecast in snapshot.DailyForecasts)
|
|
{
|
|
if (forecast.Date == date)
|
|
{
|
|
return forecast;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private string ResolveForecastDayLabel(DateOnly date, int offset)
|
|
{
|
|
if (offset == 0)
|
|
{
|
|
return L("weather.multiday.today", "Today");
|
|
}
|
|
|
|
if (offset == 1)
|
|
{
|
|
return L("weather.multiday.tomorrow", "Tomorrow");
|
|
}
|
|
|
|
var dateTime = date.ToDateTime(TimeOnly.MinValue);
|
|
var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
|
|
if (isZh)
|
|
{
|
|
var weekday = dateTime.ToString("ddd", CultureInfo.GetCultureInfo("zh-CN"));
|
|
return weekday
|
|
.Replace("星期", "周", StringComparison.Ordinal)
|
|
.Replace("周周", "周", StringComparison.Ordinal);
|
|
}
|
|
|
|
try
|
|
{
|
|
return dateTime.ToString("ddd", CultureInfo.GetCultureInfo(_languageCode));
|
|
}
|
|
catch
|
|
{
|
|
return dateTime.ToString("ddd", CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
|
|
private static string ResolvePreciseDisplayLocation(string? rawName, string languageCode, string fallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(rawName))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
var name = rawName.Trim();
|
|
if (name.Length == 0)
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
var isZh = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
|
|
var candidates = new List<string> { name };
|
|
|
|
// Prefer detailed parts inside parenthesis, e.g. "Beijing (Haidian)".
|
|
var parenthesisMatches = Regex.Matches(name, @"\(([^()]+)\)|\uFF08([^\uFF08\uFF09]+)\uFF09");
|
|
foreach (Match match in parenthesisMatches)
|
|
{
|
|
var inner = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
|
|
if (!string.IsNullOrWhiteSpace(inner))
|
|
{
|
|
candidates.Add(inner.Trim());
|
|
}
|
|
}
|
|
|
|
var nameWithoutParenthesis = Regex.Replace(name, @"\([^()]*\)|\uFF08[^\uFF08\uFF09]*\uFF09", " ");
|
|
candidates.Add(nameWithoutParenthesis);
|
|
|
|
const string splitPattern = @"[\s\|/\\,\uFF0C\u3001\u00B7]+";
|
|
foreach (var piece in Regex.Split(string.Join(" ", candidates), splitPattern))
|
|
{
|
|
var token = piece.Trim();
|
|
if (!string.IsNullOrWhiteSpace(token))
|
|
{
|
|
candidates.Add(token);
|
|
}
|
|
}
|
|
|
|
var best = fallback;
|
|
var bestScore = int.MinValue;
|
|
foreach (var candidate in candidates
|
|
.Select(c => c.Trim())
|
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var score = ScoreLocationToken(candidate, isZh);
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
best = candidate;
|
|
}
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(best) ? fallback : best;
|
|
}
|
|
|
|
private static int ScoreLocationToken(string token, bool isZh)
|
|
{
|
|
var cleaned = token.Trim();
|
|
if (cleaned.Length == 0)
|
|
{
|
|
return int.MinValue;
|
|
}
|
|
|
|
if (Regex.IsMatch(cleaned, @"^[0-9.+-]+$") ||
|
|
cleaned.StartsWith("coord:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return -500;
|
|
}
|
|
|
|
var score = Math.Min(cleaned.Length, 32);
|
|
if (isZh)
|
|
{
|
|
// Prefer granular places: street > district > city > province.
|
|
if (cleaned.EndsWith("\u8857\u9053", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u8DEF", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u793E\u533A", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u6751", StringComparison.Ordinal))
|
|
{
|
|
score += 120;
|
|
}
|
|
else if (cleaned.EndsWith("\u9547", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u4E61", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u65B0\u533A", StringComparison.Ordinal))
|
|
{
|
|
score += 100;
|
|
}
|
|
else if (cleaned.EndsWith("\u533A", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u53BF", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u65D7", StringComparison.Ordinal))
|
|
{
|
|
score += 80;
|
|
}
|
|
else if (cleaned.EndsWith("\u5E02", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u5DDE", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u76DF", StringComparison.Ordinal))
|
|
{
|
|
score += 60;
|
|
}
|
|
else if (cleaned.EndsWith("\u7701", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u81EA\u6CBB\u533A", StringComparison.Ordinal) ||
|
|
cleaned.EndsWith("\u7279\u522B\u884C\u653F\u533A", StringComparison.Ordinal))
|
|
{
|
|
score += 40;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var lower = cleaned.ToLowerInvariant();
|
|
if (lower.Contains("street", StringComparison.Ordinal) ||
|
|
lower.Contains("st.", StringComparison.Ordinal) ||
|
|
lower.Contains("road", StringComparison.Ordinal) ||
|
|
lower.Contains("rd.", StringComparison.Ordinal) ||
|
|
lower.Contains("avenue", StringComparison.Ordinal) ||
|
|
lower.Contains("district", StringComparison.Ordinal))
|
|
{
|
|
score += 120;
|
|
}
|
|
else if (lower.Contains("county", StringComparison.Ordinal) ||
|
|
lower.Contains("borough", StringComparison.Ordinal))
|
|
{
|
|
score += 90;
|
|
}
|
|
else if (lower.Contains("city", StringComparison.Ordinal))
|
|
{
|
|
score += 70;
|
|
}
|
|
else if (lower.Contains("province", StringComparison.Ordinal) ||
|
|
lower.Contains("state", StringComparison.Ordinal))
|
|
{
|
|
score += 50;
|
|
}
|
|
else if (lower.Contains("country", StringComparison.Ordinal))
|
|
{
|
|
score += 30;
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
private void ApplyAdaptiveTypography()
|
|
{
|
|
var (layoutWidth, layoutHeight) = ResolveLayoutViewport();
|
|
var innerWidth = Math.Max(120, layoutWidth);
|
|
var innerHeight = Math.Max(56, layoutHeight);
|
|
var fitScale = Math.Clamp(Math.Min(innerWidth / 592d, innerHeight / 284d), 0.30, 3.20);
|
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 3.60);
|
|
var visualScale = Math.Clamp((fitScale * 0.72) + (cellScale * 0.28), 0.30, 3.60);
|
|
var emphasis = Math.Clamp((visualScale - 0.82) / 1.90, 0, 1);
|
|
|
|
ContentGrid.RowSpacing = Math.Clamp(8 * fitScale, 1, 20);
|
|
TopRowGrid.ColumnSpacing = Math.Clamp(11 * fitScale, 3, 30);
|
|
BottomInfoStack.Margin = new Thickness(0, 0, 0, Math.Clamp(1.2 * fitScale, 0, 7));
|
|
|
|
var separatorHeight = Math.Clamp(2.0 * fitScale, 1, 8);
|
|
var contentHeight = Math.Max(36, innerHeight - ContentGrid.RowSpacing - separatorHeight);
|
|
var topZoneHeight = Math.Clamp(contentHeight * 0.47, 24, Math.Max(24, contentHeight - 12));
|
|
var bottomZoneHeight = Math.Max(10, contentHeight - topZoneHeight);
|
|
if (ContentGrid.RowDefinitions.Count >= 3)
|
|
{
|
|
ContentGrid.RowDefinitions[0].Height = new GridLength(topZoneHeight, GridUnitType.Pixel);
|
|
ContentGrid.RowDefinitions[1].Height = new GridLength(separatorHeight, GridUnitType.Pixel);
|
|
ContentGrid.RowDefinitions[2].Height = new GridLength(1, GridUnitType.Star);
|
|
}
|
|
|
|
var topScale = Math.Clamp(((topZoneHeight / 116d) * 0.42) + (visualScale * 0.86), 0.24, 3.90);
|
|
var bottomScale = Math.Clamp(((bottomZoneHeight / 156d) * 0.44) + (visualScale * 0.72), 0.24, 3.80);
|
|
var iconGrowth = Math.Clamp((visualScale - 0.88) / 1.70, 0, 1);
|
|
var iconScaleBoost = ResolveHeroIconScaleBoost(_activeVisualKind);
|
|
var iconSize = Math.Clamp(Lerp(88, 116, iconGrowth) * topScale * iconScaleBoost, 14, 360);
|
|
iconSize = Math.Min(iconSize, Math.Max(14, innerWidth * Lerp(0.22, 0.32, iconGrowth)));
|
|
var temperatureSample = string.IsNullOrWhiteSpace(TemperatureTextBlock.Text)
|
|
? "00°"
|
|
: TemperatureTextBlock.Text.Trim();
|
|
var temperatureMaxWidth = Math.Max(28, innerWidth - iconSize - TopRowGrid.ColumnSpacing - 4);
|
|
var rawTemperatureSize = Math.Clamp(Lerp(64, 92, iconGrowth) * topScale, 12, 320);
|
|
var temperatureLayout = ComponentTypographyLayoutService.FitAdaptiveTextLayout(
|
|
TemperatureTextBlock.Text,
|
|
temperatureMaxWidth,
|
|
Math.Max(18, topZoneHeight * 0.84),
|
|
1,
|
|
1,
|
|
Math.Max(9, rawTemperatureSize * 0.42),
|
|
rawTemperatureSize,
|
|
[ToVariableWeight(Lerp(300, 360, emphasis))],
|
|
1.02);
|
|
TemperatureTextBlock.FontSize = temperatureLayout.FontSize;
|
|
TemperatureTextBlock.FontWeight = temperatureLayout.Weight;
|
|
TemperatureTextBlock.Margin = new Thickness(0, Math.Clamp(-2.0 * topScale, -10, 0), 0, 0);
|
|
TemperatureTextBlock.MaxWidth = Math.Clamp(temperatureMaxWidth, 28, Math.Max(280, innerWidth * 0.68));
|
|
|
|
var cityBadge = ComponentTypographyLayoutService.ResolveBadgeBox(
|
|
innerWidth * 0.37,
|
|
Math.Max(16, topZoneHeight * 0.34),
|
|
preferredSizeScale: 0.28d,
|
|
minSize: 10,
|
|
maxSize: 24,
|
|
insetScale: 0.18d);
|
|
CityInfoBadge.Padding = cityBadge.Padding;
|
|
CityInfoBadge.CornerRadius = new CornerRadius(cityBadge.Size / 2d);
|
|
CityInfoBadge.MaxWidth = Math.Clamp(innerWidth * 0.37, 34, 460);
|
|
LocationIcon.FontSize = Math.Clamp(13 * topScale, 6, 52);
|
|
var cityLayout = ComponentTypographyLayoutService.FitAdaptiveTextLayout(
|
|
CityTextBlock.Text,
|
|
Math.Max(24, CityInfoBadge.MaxWidth - cityBadge.Padding.Left - cityBadge.Padding.Right),
|
|
Math.Max(12, topZoneHeight * 0.36),
|
|
1,
|
|
1,
|
|
6,
|
|
Math.Max(6, 18.5 * topScale),
|
|
[ToVariableWeight(Lerp(530, 620, emphasis))],
|
|
1.08);
|
|
CityTextBlock.FontSize = cityLayout.FontSize;
|
|
CityTextBlock.FontWeight = cityLayout.Weight;
|
|
CityTextBlock.LineHeight = cityLayout.LineHeight;
|
|
CityTextBlock.MaxWidth = CityInfoBadge.MaxWidth;
|
|
|
|
var conditionBadge = ComponentTypographyLayoutService.ResolveBadgeBox(
|
|
innerWidth * 0.24,
|
|
Math.Max(16, topZoneHeight * 0.34),
|
|
preferredSizeScale: 0.26d,
|
|
minSize: 10,
|
|
maxSize: 24,
|
|
insetScale: 0.18d);
|
|
ConditionInfoBadge.Padding = conditionBadge.Padding;
|
|
ConditionInfoBadge.CornerRadius = new CornerRadius(conditionBadge.Size / 2d);
|
|
ConditionInfoBadge.MaxWidth = Math.Clamp(innerWidth * 0.24, 26, 320);
|
|
ConditionIconStack.Spacing = Math.Clamp(8.5 * topScale, 1, 24);
|
|
var conditionLayout = ComponentTypographyLayoutService.FitAdaptiveTextLayout(
|
|
ConditionTextBlock.Text,
|
|
Math.Max(24, ConditionInfoBadge.MaxWidth - conditionBadge.Padding.Left - conditionBadge.Padding.Right),
|
|
Math.Max(12, topZoneHeight * 0.30),
|
|
1,
|
|
1,
|
|
7,
|
|
Math.Max(6, 19 * topScale),
|
|
[ToVariableWeight(Lerp(580, 660, emphasis))],
|
|
1.06);
|
|
var rangeLayout = ComponentTypographyLayoutService.FitAdaptiveTextLayout(
|
|
RangeTextBlock.Text,
|
|
Math.Max(24, ConditionInfoBadge.MaxWidth - conditionBadge.Padding.Left - conditionBadge.Padding.Right),
|
|
Math.Max(12, topZoneHeight * 0.30),
|
|
1,
|
|
1,
|
|
7,
|
|
Math.Max(6, 21 * topScale),
|
|
[ToVariableWeight(Lerp(600, 680, emphasis))],
|
|
1.06);
|
|
ConditionTextBlock.FontSize = conditionLayout.FontSize;
|
|
ConditionTextBlock.FontWeight = conditionLayout.Weight;
|
|
ConditionTextBlock.LineHeight = conditionLayout.LineHeight;
|
|
RangeTextBlock.FontSize = rangeLayout.FontSize;
|
|
RangeTextBlock.FontWeight = rangeLayout.Weight;
|
|
RangeTextBlock.LineHeight = rangeLayout.LineHeight;
|
|
ConditionTextBlock.MaxWidth = ConditionInfoBadge.MaxWidth;
|
|
RangeTextBlock.MaxWidth = ConditionInfoBadge.MaxWidth;
|
|
BottomInfoStack.Spacing = Math.Clamp(2.0 * topScale, 0.4, 14);
|
|
|
|
WeatherIconImage.Width = iconSize;
|
|
WeatherIconImage.Height = iconSize;
|
|
WeatherIconImage.Margin = new Thickness(0, Math.Clamp(-2.2 * topScale, -10, 0), 0, 0);
|
|
|
|
HourlyPanelBorder.Padding = new Thickness(0);
|
|
HourlyPanelBorder.Margin = new Thickness(0, Math.Clamp(6 * fitScale, 1, 24), 0, 0);
|
|
HourlyPanelBorder.CornerRadius = new CornerRadius(0);
|
|
HourlyGrid.ColumnSpacing = Math.Clamp(5 * fitScale, 0.5, 28);
|
|
var hourlyColumnCount = Math.Max(1, _hourlyTimeBlocks.Length);
|
|
var hourlyInnerWidth = Math.Max(
|
|
32,
|
|
innerWidth - (HourlyGrid.ColumnSpacing * (hourlyColumnCount - 1)));
|
|
var hourlyCellWidth = Math.Max(12, hourlyInnerWidth / hourlyColumnCount);
|
|
var hourlyCellScale = Math.Clamp(
|
|
Math.Min((bottomScale * 0.66) + (visualScale * 0.44), hourlyCellWidth / 78d),
|
|
0.22,
|
|
3.60);
|
|
var stackSpacing = Math.Clamp(2 * hourlyCellScale, 0.2, 10);
|
|
var forecastRangeSize = Math.Clamp(18.0 * hourlyCellScale, 6, 62);
|
|
var forecastLabelSize = Math.Clamp(13.8 * hourlyCellScale, 6, 48);
|
|
var forecastIconSize = Math.Clamp(40 * hourlyCellScale, 9, 124);
|
|
forecastIconSize = Math.Min(forecastIconSize, Math.Max(10, hourlyCellWidth * 0.88));
|
|
forecastIconSize = Math.Min(forecastIconSize, Math.Max(10, bottomZoneHeight * 0.50));
|
|
|
|
for (var i = 0; i < _hourlyTimeBlocks.Length; i++)
|
|
{
|
|
var hourlyTimeLayout = ComponentTypographyLayoutService.FitAdaptiveTextLayout(
|
|
_hourlyTimeBlocks[i].Text,
|
|
Math.Clamp(hourlyCellWidth, 12, 260),
|
|
Math.Max(10, bottomZoneHeight * 0.34),
|
|
1,
|
|
1,
|
|
6,
|
|
forecastLabelSize,
|
|
[ToVariableWeight(Lerp(500, 600, emphasis))],
|
|
1.02);
|
|
var hourlyTempLayout = ComponentTypographyLayoutService.FitAdaptiveTextLayout(
|
|
_hourlyTempBlocks[i].Text,
|
|
Math.Clamp(hourlyCellWidth, 12, 260),
|
|
Math.Max(10, bottomZoneHeight * 0.42),
|
|
1,
|
|
1,
|
|
6,
|
|
forecastRangeSize,
|
|
[ToVariableWeight(Lerp(580, 690, emphasis))],
|
|
1.02);
|
|
_hourlyTimeBlocks[i].FontSize = hourlyTimeLayout.FontSize;
|
|
_hourlyTempBlocks[i].FontSize = hourlyTempLayout.FontSize;
|
|
_hourlyIconBlocks[i].Width = forecastIconSize;
|
|
_hourlyIconBlocks[i].Height = forecastIconSize;
|
|
_hourlyTimeBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260);
|
|
_hourlyTempBlocks[i].MaxWidth = Math.Clamp(hourlyCellWidth, 12, 260);
|
|
_hourlyTimeBlocks[i].FontWeight = hourlyTimeLayout.Weight;
|
|
_hourlyTempBlocks[i].FontWeight = hourlyTempLayout.Weight;
|
|
_hourlyTimeBlocks[i].TextAlignment = TextAlignment.Center;
|
|
_hourlyTempBlocks[i].TextAlignment = TextAlignment.Center;
|
|
if (_hourlyTimeBlocks[i].Parent is StackPanel hourlyStack)
|
|
{
|
|
hourlyStack.Spacing = stackSpacing;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static double Lerp(double from, double to, double t)
|
|
{
|
|
return from + ((to - from) * t);
|
|
}
|
|
|
|
private static double ResolveHeroIconScaleBoost(WeatherVisualKind kind)
|
|
{
|
|
return kind switch
|
|
{
|
|
WeatherVisualKind.RainLight or WeatherVisualKind.RainHeavy or WeatherVisualKind.Storm or WeatherVisualKind.Snow => 1.16,
|
|
WeatherVisualKind.ClearNight or WeatherVisualKind.PartlyCloudyNight or WeatherVisualKind.CloudyNight => 1.08,
|
|
WeatherVisualKind.Haze or WeatherVisualKind.Fog => 1.04,
|
|
_ => 1.0
|
|
};
|
|
}
|
|
|
|
private void SetMainWeatherIcon(string? assetUri, WeatherVisualKind fallbackKind)
|
|
{
|
|
WeatherIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
|
assetUri ?? HyperOS3WeatherTheme.ResolveHeroIconAsset(ToThemeKind(fallbackKind)));
|
|
}
|
|
|
|
private void SetLoadingSkeleton(bool isLoading)
|
|
{
|
|
var opacity = isLoading ? 0.58 : 1.0;
|
|
TemperatureTextBlock.Opacity = opacity;
|
|
ConditionTextBlock.Opacity = opacity;
|
|
RangeTextBlock.Opacity = opacity;
|
|
CityTextBlock.Opacity = isLoading ? 0.50 : 0.96;
|
|
for (var i = 0; i < _hourlyTempBlocks.Length; i++)
|
|
{
|
|
_hourlyTempBlocks[i].Opacity = opacity;
|
|
_hourlyTimeBlocks[i].Opacity = isLoading ? 0.76 : 0.94;
|
|
}
|
|
}
|
|
|
|
private static FontWeight ToVariableWeight(double weight)
|
|
{
|
|
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
|
|
}
|
|
|
|
private WeatherMotionProfile ResolveMotionProfile(WeatherVisualKind kind)
|
|
{
|
|
var motion = HyperOS3WeatherTheme.ResolveMotion(ToThemeKind(kind));
|
|
return new WeatherMotionProfile(
|
|
motion.DriftX,
|
|
motion.DriftY,
|
|
motion.ZoomBase,
|
|
motion.ZoomAmplitude,
|
|
motion.MotionOpacityBase,
|
|
motion.MotionOpacityPulse,
|
|
motion.LightOpacityBase,
|
|
motion.LightOpacityPulse,
|
|
motion.ShadeOpacityBase,
|
|
motion.ShadeOpacityPulse,
|
|
motion.PhaseStep,
|
|
motion.ParticleCount,
|
|
motion.ParticleSpeedMin,
|
|
motion.ParticleSpeedMax,
|
|
motion.ParticleLengthMin,
|
|
motion.ParticleLengthMax,
|
|
motion.ParticleDriftPerTick);
|
|
}
|
|
|
|
private void ResetAnimationState()
|
|
{
|
|
var motion = ResolveMotionProfile(_activeVisualKind);
|
|
_animationPhase = 0;
|
|
SetMotionTransform(0, 0, motion.ZoomBase);
|
|
BackgroundMotionLayer.Opacity = motion.MotionOpacityBase;
|
|
BackgroundLightLayer.Opacity = motion.LightOpacityBase;
|
|
BackgroundShadeLayer.Opacity = motion.ShadeOpacityBase;
|
|
}
|
|
|
|
private void SetMotionTransform(double translateX, double translateY, double scale)
|
|
{
|
|
_backgroundMotionScaleTransform.ScaleX = scale;
|
|
_backgroundMotionScaleTransform.ScaleY = scale;
|
|
_backgroundMotionTranslateTransform.X = translateX;
|
|
_backgroundMotionTranslateTransform.Y = translateY;
|
|
}
|
|
|
|
private void InitializeMotionTransform()
|
|
{
|
|
BackgroundMotionLayer.RenderTransform = new TransformGroup
|
|
{
|
|
Children = new Transforms
|
|
{
|
|
_backgroundMotionScaleTransform,
|
|
_backgroundMotionTranslateTransform
|
|
}
|
|
};
|
|
}
|
|
|
|
private void UpdateTimerState()
|
|
{
|
|
if (_isAttached && _isOnActivePage)
|
|
{
|
|
if (_autoRefreshEnabled && !_refreshTimer.IsEnabled)
|
|
{
|
|
_refreshTimer.Start();
|
|
}
|
|
else if (!_autoRefreshEnabled && _refreshTimer.IsEnabled)
|
|
{
|
|
_refreshTimer.Stop();
|
|
}
|
|
|
|
if (!_backgroundAnimationTimer.IsEnabled)
|
|
{
|
|
_backgroundAnimationTimer.Start();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
_refreshTimer.Stop();
|
|
_backgroundAnimationTimer.Stop();
|
|
}
|
|
|
|
private void ApplyAutoRefreshSettings()
|
|
{
|
|
var enabled = true;
|
|
var intervalMinutes = 12;
|
|
|
|
try
|
|
{
|
|
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
|
enabled = snapshot.WeatherAutoRefreshEnabled;
|
|
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
|
}
|
|
catch
|
|
{
|
|
// Keep fallback defaults.
|
|
}
|
|
|
|
_autoRefreshEnabled = enabled;
|
|
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
|
|
|
|
if (_isAttached)
|
|
{
|
|
UpdateTimerState();
|
|
}
|
|
}
|
|
|
|
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
|
|
{
|
|
if (minutes <= 0)
|
|
{
|
|
return 12;
|
|
}
|
|
|
|
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
|
|
{
|
|
return minutes;
|
|
}
|
|
|
|
return SupportedAutoRefreshIntervalsMinutes
|
|
.OrderBy(value => Math.Abs(value - minutes))
|
|
.FirstOrDefault(12);
|
|
}
|
|
|
|
private void InitializeParticleVisuals()
|
|
{
|
|
if (_particleVisuals.Count > 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const int maxParticles = 40;
|
|
for (var i = 0; i < maxParticles; i++)
|
|
{
|
|
var particle = new Border
|
|
{
|
|
IsVisible = false,
|
|
Width = 2,
|
|
Height = 14,
|
|
CornerRadius = new CornerRadius(1),
|
|
Opacity = 0.0
|
|
};
|
|
_particleVisuals.Add(particle);
|
|
_particleStates.Add(new ParticleState());
|
|
ParticleLayer.Children.Add(particle);
|
|
}
|
|
}
|
|
|
|
private void ResetParticles()
|
|
{
|
|
if (_particleVisuals.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var motion = ResolveMotionProfile(_activeVisualKind);
|
|
_activeParticleCount = Math.Clamp(motion.ParticleCount, 0, _particleVisuals.Count);
|
|
|
|
var (width, height) = ResolveParticleViewport();
|
|
|
|
for (var i = 0; i < _particleVisuals.Count; i++)
|
|
{
|
|
var particle = _particleVisuals[i];
|
|
if (i >= _activeParticleCount)
|
|
{
|
|
particle.IsVisible = false;
|
|
continue;
|
|
}
|
|
|
|
particle.IsVisible = true;
|
|
RespawnParticle(i, width, height, motion, initialPlacement: true);
|
|
}
|
|
}
|
|
|
|
private void AdvanceParticles(WeatherMotionProfile motion)
|
|
{
|
|
if (_activeParticleCount <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var (width, height) = ResolveParticleViewport();
|
|
|
|
for (var i = 0; i < _activeParticleCount; i++)
|
|
{
|
|
var particle = _particleVisuals[i];
|
|
var state = _particleStates[i];
|
|
|
|
var x = Canvas.GetLeft(particle);
|
|
var y = Canvas.GetTop(particle);
|
|
if (double.IsNaN(x))
|
|
{
|
|
x = 0;
|
|
}
|
|
|
|
if (double.IsNaN(y))
|
|
{
|
|
y = -20;
|
|
}
|
|
|
|
var sway = _activeVisualKind == WeatherVisualKind.Snow
|
|
? Math.Sin(_animationPhase + (i * 0.45)) * 0.55
|
|
: _activeVisualKind is WeatherVisualKind.Fog or WeatherVisualKind.Haze
|
|
? Math.Sin((_animationPhase * 0.7) + (i * 0.31)) * 0.18
|
|
: 0;
|
|
|
|
x += state.Drift + sway;
|
|
y += state.Speed;
|
|
|
|
Canvas.SetLeft(particle, x);
|
|
Canvas.SetTop(particle, y);
|
|
|
|
if (y > height + 48 || x > width + 56 || x < -72)
|
|
{
|
|
RespawnParticle(i, width, height, motion, initialPlacement: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RespawnParticle(int index, double width, double height, WeatherMotionProfile motion, bool initialPlacement)
|
|
{
|
|
var particle = _particleVisuals[index];
|
|
var state = _particleStates[index];
|
|
|
|
state.Speed = NextRange(motion.ParticleSpeedMin, motion.ParticleSpeedMax);
|
|
var driftVariance = Math.Abs(motion.ParticleDriftPerTick) * 0.35;
|
|
state.Drift = motion.ParticleDriftPerTick + NextRange(-driftVariance, driftVariance);
|
|
|
|
var length = NextRange(motion.ParticleLengthMin, motion.ParticleLengthMax);
|
|
var thickness = _activeVisualKind switch
|
|
{
|
|
WeatherVisualKind.Snow => NextRange(2.2, 4.3),
|
|
WeatherVisualKind.Fog or WeatherVisualKind.Haze => NextRange(10.0, 22.0),
|
|
_ => NextRange(1.0, 2.2)
|
|
};
|
|
var opacity = _activeVisualKind switch
|
|
{
|
|
WeatherVisualKind.Storm => NextRange(0.26, 0.52),
|
|
WeatherVisualKind.RainHeavy => NextRange(0.24, 0.46),
|
|
WeatherVisualKind.RainLight or WeatherVisualKind.Sleet => NextRange(0.18, 0.34),
|
|
WeatherVisualKind.Snow => NextRange(0.40, 0.72),
|
|
WeatherVisualKind.Fog or WeatherVisualKind.Haze => NextRange(0.08, 0.20),
|
|
_ => NextRange(0.10, 0.24)
|
|
};
|
|
|
|
particle.Width = thickness;
|
|
particle.Height = length;
|
|
particle.Opacity = opacity;
|
|
particle.CornerRadius = new CornerRadius(Math.Max(1, thickness * 0.5));
|
|
particle.RenderTransform = new RotateTransform(_activeVisualKind switch
|
|
{
|
|
WeatherVisualKind.Storm => -24,
|
|
WeatherVisualKind.RainHeavy => -20,
|
|
WeatherVisualKind.RainLight or WeatherVisualKind.Sleet => -14,
|
|
WeatherVisualKind.Snow => -6,
|
|
_ => 0
|
|
});
|
|
|
|
var x = initialPlacement
|
|
? NextRange(-40, width + 20)
|
|
: NextRange(-24, width + 20);
|
|
var y = initialPlacement
|
|
? NextRange(-height, height)
|
|
: -length - NextRange(8, 120);
|
|
|
|
Canvas.SetLeft(particle, x);
|
|
Canvas.SetTop(particle, y);
|
|
}
|
|
|
|
private double NextRange(double min, double max)
|
|
{
|
|
if (max <= min)
|
|
{
|
|
return min;
|
|
}
|
|
|
|
return min + (_particleRandom.NextDouble() * (max - min));
|
|
}
|
|
|
|
private string L(string key, string fallback)
|
|
{
|
|
return _localizationService.GetString(_languageCode, key, fallback);
|
|
}
|
|
|
|
private CultureInfo GetUiCulture()
|
|
{
|
|
try
|
|
{
|
|
return CultureInfo.GetCultureInfo(_languageCode);
|
|
}
|
|
catch
|
|
{
|
|
return CultureInfo.InvariantCulture;
|
|
}
|
|
}
|
|
|
|
private static string BuildCoordinateLocationKey(double latitude, double longitude)
|
|
{
|
|
return string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"coord:{latitude:F4},{longitude:F4}");
|
|
}
|
|
|
|
private static string BuildCoordinateLocationName(double latitude, double longitude, string languageCode)
|
|
{
|
|
var template = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
|
? "坐标 {0:F2}, {1:F2}"
|
|
: "Coordinate {0:F2}, {1:F2}";
|
|
return string.Format(CultureInfo.InvariantCulture, template, latitude, longitude);
|
|
}
|
|
|
|
private static double NormalizeLatitude(double value)
|
|
{
|
|
if (double.IsNaN(value) || double.IsInfinity(value))
|
|
{
|
|
return 39.9042;
|
|
}
|
|
|
|
return Math.Clamp(value, -90, 90);
|
|
}
|
|
|
|
private static double NormalizeLongitude(double value)
|
|
{
|
|
if (double.IsNaN(value) || double.IsInfinity(value))
|
|
{
|
|
return 116.4074;
|
|
}
|
|
|
|
return Math.Clamp(value, -180, 180);
|
|
}
|
|
|
|
private void CancelRefreshRequest()
|
|
{
|
|
var cts = Interlocked.Exchange(ref _refreshCts, null);
|
|
cts?.Cancel();
|
|
cts?.Dispose();
|
|
}
|
|
|
|
private (double Width, double Height) ResolveLayoutViewport()
|
|
{
|
|
var width = LayoutRoot.Bounds.Width;
|
|
var height = LayoutRoot.Bounds.Height;
|
|
if (width > 1 && height > 1)
|
|
{
|
|
return (width, height);
|
|
}
|
|
|
|
var fallbackWidth = Bounds.Width > 1
|
|
? Bounds.Width - ContentPaddingBorder.Padding.Left - ContentPaddingBorder.Padding.Right
|
|
: _currentCellSize * 4;
|
|
var fallbackHeight = Bounds.Height > 1
|
|
? Bounds.Height - ContentPaddingBorder.Padding.Top - ContentPaddingBorder.Padding.Bottom
|
|
: _currentCellSize * 2;
|
|
|
|
return (Math.Max(100, fallbackWidth), Math.Max(56, fallbackHeight));
|
|
}
|
|
|
|
private (double Width, double Height) ResolveParticleViewport()
|
|
{
|
|
var width = Bounds.Width > 1 ? Bounds.Width : LayoutRoot.Bounds.Width;
|
|
var height = Bounds.Height > 1 ? Bounds.Height : LayoutRoot.Bounds.Height;
|
|
return (Math.Max(80, width), Math.Max(56, height));
|
|
}
|
|
|
|
private double ResolveScale()
|
|
{
|
|
var (layoutWidth, layoutHeight) = ResolveLayoutViewport();
|
|
return ResolveScale(layoutWidth, layoutHeight);
|
|
}
|
|
|
|
private double ResolveScale(double layoutWidth, double layoutHeight)
|
|
{
|
|
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.34, 2.30);
|
|
var heightScale = Math.Clamp(layoutHeight / 320d, 0.34, 2.30);
|
|
var widthScale = Math.Clamp(layoutWidth / 620d, 0.34, 2.30);
|
|
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.34, 2.30);
|
|
}
|
|
|
|
private static IBrush CreateSolidBrush(string colorHex)
|
|
{
|
|
return new SolidColorBrush(Color.Parse(colorHex));
|
|
}
|
|
|
|
private static IBrush CreateSolidBrush(string colorHex, byte alpha)
|
|
{
|
|
var color = Color.Parse(colorHex);
|
|
return new SolidColorBrush(Color.FromArgb(alpha, color.R, color.G, color.B));
|
|
}
|
|
|
|
private static IBrush CreateGradientBrush(string fromColorHex, string toColorHex)
|
|
{
|
|
return new LinearGradientBrush
|
|
{
|
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
|
GradientStops = new GradientStops
|
|
{
|
|
new GradientStop(Color.Parse(fromColorHex), 0),
|
|
new GradientStop(Color.Parse(toColorHex), 1)
|
|
}
|
|
};
|
|
}
|
|
}
|