Add WinUI and core source
build-winui / winui (push) Has been cancelled
build-winui / winui (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,832 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Net;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public sealed record TitleWeatherSnapshot(
|
||||
bool IsAvailable,
|
||||
string Location,
|
||||
string Condition,
|
||||
string TemperatureText,
|
||||
string FeelsLikeText,
|
||||
string HumidityText,
|
||||
string WindText,
|
||||
string RangeText,
|
||||
string UpdatedText,
|
||||
string IconGlyph,
|
||||
int? WeatherCode,
|
||||
WeatherVisualKind VisualKind,
|
||||
WeatherIntensity Intensity,
|
||||
string QueryLevel,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static TitleWeatherSnapshot Loading { get; } = new(
|
||||
false,
|
||||
AppLocalizer.T("定位中", "Locating"),
|
||||
AppLocalizer.T("正在获取天气", "Loading weather"),
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
"\uE753",
|
||||
null,
|
||||
WeatherVisualKind.Unknown,
|
||||
WeatherIntensity.None,
|
||||
AppLocalizer.T("定位中", "Locating"));
|
||||
|
||||
public static TitleWeatherSnapshot Offline(string? error = null) => new(
|
||||
false,
|
||||
AppLocalizer.T("天气", "Weather"),
|
||||
AppLocalizer.T("暂不可用", "Unavailable"),
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
"--",
|
||||
AppLocalizer.T("离线", "Offline"),
|
||||
"\uE783",
|
||||
null,
|
||||
WeatherVisualKind.Unknown,
|
||||
WeatherIntensity.None,
|
||||
AppLocalizer.T("离线", "Offline"),
|
||||
error);
|
||||
}
|
||||
|
||||
public enum WeatherVisualKind
|
||||
{
|
||||
Unknown,
|
||||
Clear,
|
||||
PartlyCloudy,
|
||||
Cloudy,
|
||||
Fog,
|
||||
Drizzle,
|
||||
Rain,
|
||||
FreezingRain,
|
||||
Snow,
|
||||
SnowGrains,
|
||||
Showers,
|
||||
SnowShowers,
|
||||
Thunderstorm
|
||||
}
|
||||
|
||||
public enum WeatherIntensity
|
||||
{
|
||||
None,
|
||||
Light,
|
||||
Moderate,
|
||||
Heavy
|
||||
}
|
||||
|
||||
public interface ITitleWeatherService
|
||||
{
|
||||
Task<TitleWeatherSnapshot> GetCurrentAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class TitleWeatherService(
|
||||
IHttpService httpService,
|
||||
ILogService? logService = null) : ITitleWeatherService
|
||||
{
|
||||
private const double DistrictSearchMaxDistanceKm = 180;
|
||||
private const double CitySearchMaxDistanceKm = 500;
|
||||
|
||||
private static readonly Uri IpApiLocationUri = new("https://ipapi.co/json/");
|
||||
private static readonly Uri ClientLocationZhUri = BuildClientLocationUri("zh-Hans");
|
||||
private static readonly Uri ClientLocationEnUri = BuildClientLocationUri("en");
|
||||
|
||||
private static readonly WeatherLocation DefaultLocation = new(
|
||||
DisplayDistrict: string.Empty,
|
||||
QueryDistrict: string.Empty,
|
||||
DistrictGeoNameId: null,
|
||||
DisplayCity: "上海市",
|
||||
QueryCity: "Shanghai",
|
||||
CityGeoNameId: 1796236,
|
||||
DisplayRegion: "上海市",
|
||||
QueryRegion: "Shanghai Municipality",
|
||||
DisplayCountry: "中国",
|
||||
QueryCountry: "China",
|
||||
CountryCode: "CN",
|
||||
Latitude: 31.2304,
|
||||
Longitude: 121.4737);
|
||||
|
||||
public async Task<TitleWeatherSnapshot> GetCurrentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var location = await ResolveLocationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var weatherPlace = await ResolveWeatherPlaceAsync(location, cancellationToken).ConfigureAwait(false);
|
||||
var uri = BuildForecastUri(weatherPlace);
|
||||
var forecast = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
var snapshot = ParseForecast(location, weatherPlace, forecast);
|
||||
await WriteLogAsync(
|
||||
"Information",
|
||||
"weather",
|
||||
"Title weather updated",
|
||||
$"{snapshot.Location}; {snapshot.Condition}; query={weatherPlace.QueryLevel}").ConfigureAwait(false);
|
||||
return snapshot;
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
|
||||
await WriteLogAsync("Warning", "weather", "Title weather unavailable", safe).ConfigureAwait(false);
|
||||
return TitleWeatherSnapshot.Offline(safe);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WeatherLocation> ResolveLocationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var ipLocation = await TryResolveIpApiLocationAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (ipLocation is not null)
|
||||
{
|
||||
var zhFromIpTask = TryReadClientLocationAsync(
|
||||
BuildClientLocationUri("zh-Hans", ipLocation.Latitude, ipLocation.Longitude),
|
||||
cancellationToken);
|
||||
var enFromIpTask = TryReadClientLocationAsync(
|
||||
BuildClientLocationUri("en", ipLocation.Latitude, ipLocation.Longitude),
|
||||
cancellationToken);
|
||||
await Task.WhenAll(zhFromIpTask, enFromIpTask).ConfigureAwait(false);
|
||||
|
||||
var zhFromIp = zhFromIpTask.Result;
|
||||
var enFromIp = enFromIpTask.Result;
|
||||
if (zhFromIp is not null || enFromIp is not null)
|
||||
{
|
||||
return MergeClientLocations(zhFromIp, enFromIp, ipLocation);
|
||||
}
|
||||
|
||||
return FromIpLocation(ipLocation);
|
||||
}
|
||||
|
||||
var zhTask = TryReadClientLocationAsync(ClientLocationZhUri, cancellationToken);
|
||||
var enTask = TryReadClientLocationAsync(ClientLocationEnUri, cancellationToken);
|
||||
await Task.WhenAll(zhTask, enTask).ConfigureAwait(false);
|
||||
|
||||
var zh = zhTask.Result;
|
||||
var en = enTask.Result;
|
||||
if (zh is not null || en is not null)
|
||||
{
|
||||
return MergeClientLocations(zh, en);
|
||||
}
|
||||
|
||||
return DefaultLocation;
|
||||
}
|
||||
|
||||
private async Task<ClientLocation?> TryReadClientLocationAsync(Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
return ParseClientLocation(content);
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IpLocation?> TryResolveIpApiLocationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await httpService.GetStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(content);
|
||||
var root = document.RootElement;
|
||||
var latitude = GetDouble(root, "latitude");
|
||||
var longitude = GetDouble(root, "longitude");
|
||||
if (latitude is null || longitude is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var city = FirstNonEmpty(GetString(root, "city"));
|
||||
var region = FirstNonEmpty(GetString(root, "region"), GetString(root, "region_name"));
|
||||
var country = FirstNonEmpty(GetString(root, "country_name"), GetString(root, "country"));
|
||||
var countryCode = FirstNonEmpty(GetString(root, "country_code"), DefaultLocation.CountryCode).ToUpperInvariant();
|
||||
return new IpLocation(
|
||||
city,
|
||||
region,
|
||||
country,
|
||||
countryCode,
|
||||
latitude.Value,
|
||||
longitude.Value);
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WeatherPlace> ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
|
||||
{
|
||||
if (location.DistrictGeoNameId is long districtId &&
|
||||
await TryGetGeocodedPlaceByIdAsync(districtId, cancellationToken).ConfigureAwait(false) is { } districtPlace &&
|
||||
IsNearExpectedLocation(districtPlace, location, DistrictSearchMaxDistanceKm))
|
||||
{
|
||||
return new WeatherPlace(
|
||||
FormatDisplayLocation(location, preferDistrict: true),
|
||||
AppLocalizer.T("区/县", "District"),
|
||||
districtPlace.Latitude,
|
||||
districtPlace.Longitude);
|
||||
}
|
||||
|
||||
if (location.CityGeoNameId is long cityId &&
|
||||
await TryGetGeocodedPlaceByIdAsync(cityId, cancellationToken).ConfigureAwait(false) is { } cityPlace &&
|
||||
IsNearExpectedLocation(cityPlace, location, CitySearchMaxDistanceKm))
|
||||
{
|
||||
return new WeatherPlace(
|
||||
FormatDisplayLocation(location, preferDistrict: false),
|
||||
AppLocalizer.T("市级", "City"),
|
||||
cityPlace.Latitude,
|
||||
cityPlace.Longitude);
|
||||
}
|
||||
|
||||
foreach (var query in BuildNameSearchQueries(location.QueryDistrict))
|
||||
{
|
||||
if (await TrySearchGeocodedPlaceAsync(query, location, DistrictSearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
|
||||
{
|
||||
return new WeatherPlace(
|
||||
FormatDisplayLocation(location, preferDistrict: true),
|
||||
AppLocalizer.T("区/县", "District"),
|
||||
place.Latitude,
|
||||
place.Longitude);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var query in BuildNameSearchQueries(location.QueryCity))
|
||||
{
|
||||
if (await TrySearchGeocodedPlaceAsync(query, location, CitySearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
|
||||
{
|
||||
return new WeatherPlace(
|
||||
FormatDisplayLocation(location, preferDistrict: false),
|
||||
AppLocalizer.T("市级", "City"),
|
||||
place.Latitude,
|
||||
place.Longitude);
|
||||
}
|
||||
}
|
||||
|
||||
return new WeatherPlace(
|
||||
FormatDisplayLocation(location, preferDistrict: true),
|
||||
AppLocalizer.T("经纬度", "Coordinates"),
|
||||
location.Latitude,
|
||||
location.Longitude);
|
||||
}
|
||||
|
||||
private async Task<GeocodedPlace?> TryGetGeocodedPlaceByIdAsync(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri($"https://geocoding-api.open-meteo.com/v1/get?id={id}&language=en&format=json");
|
||||
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(content);
|
||||
return TryParseGeocodedPlace(document.RootElement, out var place) ? place : null;
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GeocodedPlace?> TrySearchGeocodedPlaceAsync(
|
||||
string query,
|
||||
WeatherLocation expected,
|
||||
double maxDistanceKm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri("https://geocoding-api.open-meteo.com/v1/search" +
|
||||
$"?name={Uri.EscapeDataString(query)}&count=10&language=en&format=json");
|
||||
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(content);
|
||||
if (!document.RootElement.TryGetProperty("results", out var results) ||
|
||||
results.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return results
|
||||
.EnumerateArray()
|
||||
.Select(item => TryParseGeocodedPlace(item, out var place) ? place : null)
|
||||
.Where(place => place is not null)
|
||||
.Select(place => place!)
|
||||
.Where(place => CountryMatches(place, expected))
|
||||
.Select(place => new
|
||||
{
|
||||
Place = place,
|
||||
Distance = DistanceKm(expected.Latitude, expected.Longitude, place.Latitude, place.Longitude)
|
||||
})
|
||||
.Where(item => item.Distance <= maxDistanceKm)
|
||||
.OrderBy(item => item.Distance)
|
||||
.Select(item => item.Place)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildForecastUri(WeatherPlace place)
|
||||
{
|
||||
var latitude = place.Latitude.ToString("0.####", CultureInfo.InvariantCulture);
|
||||
var longitude = place.Longitude.ToString("0.####", CultureInfo.InvariantCulture);
|
||||
return new Uri(
|
||||
"https://api.open-meteo.com/v1/forecast" +
|
||||
$"?latitude={latitude}&longitude={longitude}" +
|
||||
"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m" +
|
||||
"&daily=temperature_2m_max,temperature_2m_min" +
|
||||
"&timezone=auto&forecast_days=1");
|
||||
}
|
||||
|
||||
private static Uri BuildClientLocationUri(string language, double? latitude = null, double? longitude = null)
|
||||
{
|
||||
var query = $"localityLanguage={Uri.EscapeDataString(language)}";
|
||||
if (latitude is not null && longitude is not null)
|
||||
{
|
||||
query += $"&latitude={latitude.Value.ToString("0.######", CultureInfo.InvariantCulture)}" +
|
||||
$"&longitude={longitude.Value.ToString("0.######", CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
return new Uri($"https://api.bigdatacloud.net/data/reverse-geocode-client?{query}");
|
||||
}
|
||||
|
||||
private static TitleWeatherSnapshot ParseForecast(WeatherLocation location, WeatherPlace place, string content)
|
||||
{
|
||||
using var document = JsonDocument.Parse(content);
|
||||
var root = document.RootElement;
|
||||
var current = root.GetProperty("current");
|
||||
var daily = root.TryGetProperty("daily", out var dailyElement) ? dailyElement : default;
|
||||
var code = (int)(GetDouble(current, "weather_code") ?? 0);
|
||||
var temp = GetDouble(current, "temperature_2m");
|
||||
var apparent = GetDouble(current, "apparent_temperature");
|
||||
var humidity = GetDouble(current, "relative_humidity_2m");
|
||||
var wind = GetDouble(current, "wind_speed_10m");
|
||||
var max = GetFirstArrayDouble(daily, "temperature_2m_max");
|
||||
var min = GetFirstArrayDouble(daily, "temperature_2m_min");
|
||||
var updated = FirstNonEmpty(GetString(current, "time"), DateTimeOffset.Now.ToString("HH:mm", CultureInfo.CurrentCulture));
|
||||
var condition = ConditionText(code);
|
||||
var visualKind = VisualKind(code);
|
||||
var intensity = Intensity(code);
|
||||
|
||||
return new TitleWeatherSnapshot(
|
||||
true,
|
||||
place.DisplayName,
|
||||
condition,
|
||||
FormatTemperature(temp),
|
||||
apparent is null ? "--" : AppLocalizer.T($"体感 {apparent.Value:0.#}°", $"Feels {apparent.Value:0.#}°"),
|
||||
humidity is null ? "--" : $"{humidity.Value:0}%",
|
||||
wind is null ? "--" : $"{wind.Value:0.#} km/h",
|
||||
max is null || min is null ? "--" : $"{min.Value:0.#}° / {max.Value:0.#}°",
|
||||
FormatUpdated(updated),
|
||||
WeatherGlyph(code),
|
||||
code,
|
||||
visualKind,
|
||||
intensity,
|
||||
place.QueryLevel);
|
||||
}
|
||||
|
||||
private static ClientLocation ParseClientLocation(string content)
|
||||
{
|
||||
using var document = JsonDocument.Parse(content);
|
||||
var root = document.RootElement;
|
||||
var administrative = new List<AdministrativeArea>();
|
||||
if (root.TryGetProperty("localityInfo", out var localityInfo) &&
|
||||
localityInfo.ValueKind == JsonValueKind.Object &&
|
||||
localityInfo.TryGetProperty("administrative", out var adminArray) &&
|
||||
adminArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in adminArray.EnumerateArray())
|
||||
{
|
||||
var name = GetString(item, "name");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
administrative.Add(new AdministrativeArea(
|
||||
name,
|
||||
GetString(item, "isoName"),
|
||||
GetInt(item, "adminLevel"),
|
||||
GetInt(item, "order"),
|
||||
GetLong(item, "geonameId"),
|
||||
GetString(item, "isoCode")));
|
||||
}
|
||||
}
|
||||
|
||||
return new ClientLocation(
|
||||
GetString(root, "locality"),
|
||||
GetString(root, "city"),
|
||||
GetString(root, "principalSubdivision"),
|
||||
GetString(root, "countryName"),
|
||||
GetString(root, "countryCode"),
|
||||
GetDouble(root, "latitude"),
|
||||
GetDouble(root, "longitude"),
|
||||
administrative);
|
||||
}
|
||||
|
||||
private static WeatherLocation MergeClientLocations(ClientLocation? displaySource, ClientLocation? querySource, IpLocation? fallback = null)
|
||||
{
|
||||
var zh = displaySource ?? querySource ?? ClientLocation.Empty;
|
||||
var en = querySource ?? displaySource ?? ClientLocation.Empty;
|
||||
|
||||
var displayDistrictArea = PickDistrictArea(zh);
|
||||
var queryDistrictArea = PickDistrictArea(en);
|
||||
var displayCityArea = PickCityArea(zh);
|
||||
var queryCityArea = PickCityArea(en);
|
||||
|
||||
var displayDistrict = FirstNonEmpty(zh.Locality, displayDistrictArea?.Name);
|
||||
var queryDistrict = FirstNonEmpty(en.Locality, queryDistrictArea?.Name, queryDistrictArea?.IsoName);
|
||||
var displayCity = FirstNonEmpty(zh.City, displayCityArea?.Name, zh.PrincipalSubdivision, fallback?.City);
|
||||
var queryCity = FirstNonEmpty(en.City, queryCityArea?.Name, queryCityArea?.IsoName, en.PrincipalSubdivision, fallback?.City);
|
||||
var displayRegion = FirstNonEmpty(zh.PrincipalSubdivision, displayCity, fallback?.Region, DefaultLocation.DisplayRegion);
|
||||
var queryRegion = FirstNonEmpty(en.PrincipalSubdivision, queryCity, fallback?.Region, DefaultLocation.QueryRegion);
|
||||
var displayCountry = FirstNonEmpty(zh.CountryName, fallback?.Country, DefaultLocation.DisplayCountry);
|
||||
var queryCountry = FirstNonEmpty(en.CountryName, fallback?.Country, DefaultLocation.QueryCountry);
|
||||
var countryCode = FirstNonEmpty(en.CountryCode, zh.CountryCode, fallback?.CountryCode, DefaultLocation.CountryCode).ToUpperInvariant();
|
||||
var latitude = zh.Latitude ?? en.Latitude ?? fallback?.Latitude ?? DefaultLocation.Latitude;
|
||||
var longitude = zh.Longitude ?? en.Longitude ?? fallback?.Longitude ?? DefaultLocation.Longitude;
|
||||
|
||||
return new WeatherLocation(
|
||||
displayDistrict,
|
||||
queryDistrict,
|
||||
queryDistrictArea?.GeoNameId ?? displayDistrictArea?.GeoNameId,
|
||||
displayCity,
|
||||
queryCity,
|
||||
queryCityArea?.GeoNameId ?? displayCityArea?.GeoNameId,
|
||||
displayRegion,
|
||||
queryRegion,
|
||||
displayCountry,
|
||||
queryCountry,
|
||||
countryCode,
|
||||
latitude,
|
||||
longitude);
|
||||
}
|
||||
|
||||
private static WeatherLocation FromIpLocation(IpLocation location)
|
||||
{
|
||||
var city = FirstNonEmpty(location.City, DefaultLocation.QueryCity);
|
||||
var region = FirstNonEmpty(location.Region, city, DefaultLocation.QueryRegion);
|
||||
var country = FirstNonEmpty(location.Country, DefaultLocation.QueryCountry);
|
||||
return new WeatherLocation(
|
||||
DisplayDistrict: string.Empty,
|
||||
QueryDistrict: string.Empty,
|
||||
DistrictGeoNameId: null,
|
||||
DisplayCity: city,
|
||||
QueryCity: city,
|
||||
CityGeoNameId: null,
|
||||
DisplayRegion: region,
|
||||
QueryRegion: region,
|
||||
DisplayCountry: country,
|
||||
QueryCountry: country,
|
||||
CountryCode: FirstNonEmpty(location.CountryCode, DefaultLocation.CountryCode),
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude);
|
||||
}
|
||||
|
||||
private static AdministrativeArea? PickDistrictArea(ClientLocation location)
|
||||
{
|
||||
var localityArea = FindAreaByName(location.Administrative, location.Locality);
|
||||
if (localityArea is not null && localityArea.GeoNameId is not null)
|
||||
{
|
||||
return localityArea;
|
||||
}
|
||||
|
||||
return location.Administrative
|
||||
.Where(area => area.GeoNameId is not null && area.AdminLevel is >= 6 and <= 7)
|
||||
.OrderBy(area => area.Order ?? int.MinValue)
|
||||
.LastOrDefault();
|
||||
}
|
||||
|
||||
private static AdministrativeArea? PickCityArea(ClientLocation location)
|
||||
{
|
||||
var cityArea = FindAreaByName(location.Administrative, location.City);
|
||||
if (cityArea is not null && cityArea.GeoNameId is not null)
|
||||
{
|
||||
return cityArea;
|
||||
}
|
||||
|
||||
return location.Administrative
|
||||
.Where(area => area.GeoNameId is not null && area.AdminLevel is >= 4 and <= 5)
|
||||
.OrderBy(area => area.Order ?? int.MinValue)
|
||||
.LastOrDefault();
|
||||
}
|
||||
|
||||
private static AdministrativeArea? FindAreaByName(IEnumerable<AdministrativeArea> areas, string? name)
|
||||
{
|
||||
var normalized = NormalizeLocationName(name);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return areas.FirstOrDefault(area =>
|
||||
NormalizeLocationName(area.Name) == normalized ||
|
||||
NormalizeLocationName(area.IsoName) == normalized);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildNameSearchQueries(string name)
|
||||
{
|
||||
var normalized = FirstNonEmpty(name);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return normalized;
|
||||
|
||||
var suffixes = new[]
|
||||
{
|
||||
" District",
|
||||
" County",
|
||||
" Municipality",
|
||||
" Prefecture",
|
||||
" City",
|
||||
" Shi",
|
||||
" Qu",
|
||||
" Xian"
|
||||
};
|
||||
foreach (var suffix in suffixes)
|
||||
{
|
||||
if (normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return normalized[..^suffix.Length].Trim();
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseGeocodedPlace(JsonElement element, out GeocodedPlace place)
|
||||
{
|
||||
var latitude = GetDouble(element, "latitude");
|
||||
var longitude = GetDouble(element, "longitude");
|
||||
if (latitude is null || longitude is null)
|
||||
{
|
||||
place = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
place = new GeocodedPlace(
|
||||
FirstNonEmpty(GetString(element, "name"), GetString(element, "admin3"), GetString(element, "admin2"), GetString(element, "admin1")),
|
||||
FirstNonEmpty(GetString(element, "country_code"), GetString(element, "country")),
|
||||
latitude.Value,
|
||||
longitude.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsNearExpectedLocation(GeocodedPlace place, WeatherLocation expected, double maxDistanceKm)
|
||||
{
|
||||
return CountryMatches(place, expected) &&
|
||||
DistanceKm(expected.Latitude, expected.Longitude, place.Latitude, place.Longitude) <= maxDistanceKm;
|
||||
}
|
||||
|
||||
private static bool CountryMatches(GeocodedPlace place, WeatherLocation expected)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(expected.CountryCode) ||
|
||||
string.IsNullOrWhiteSpace(place.CountryCode) ||
|
||||
string.Equals(place.CountryCode, expected.CountryCode, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string FormatDisplayLocation(WeatherLocation location, bool preferDistrict)
|
||||
{
|
||||
if (AppLocalizer.IsEnglish)
|
||||
{
|
||||
return preferDistrict
|
||||
? FirstNonEmpty(location.QueryDistrict, location.QueryCity, location.QueryRegion, location.QueryCountry, "Weather")
|
||||
: FirstNonEmpty(location.QueryCity, location.QueryRegion, location.QueryCountry, "Weather");
|
||||
}
|
||||
|
||||
return preferDistrict
|
||||
? FirstNonEmpty(location.DisplayDistrict, location.DisplayCity, location.DisplayRegion, location.DisplayCountry, "天气")
|
||||
: FirstNonEmpty(location.DisplayCity, location.DisplayRegion, location.DisplayCountry, "天气");
|
||||
}
|
||||
|
||||
private static string FormatTemperature(double? value) => value is null ? "--" : $"{value.Value:0.#}°";
|
||||
|
||||
private static string FormatUpdated(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
|
||||
{
|
||||
return parsed.LocalDateTime.ToString("HH:mm", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
return value.Length > 5 ? value[^5..] : value;
|
||||
}
|
||||
|
||||
private static string ConditionText(int code) => code switch
|
||||
{
|
||||
0 => AppLocalizer.T("晴", "Clear"),
|
||||
1 or 2 => AppLocalizer.T("少云", "Partly cloudy"),
|
||||
3 => AppLocalizer.T("阴", "Cloudy"),
|
||||
45 or 48 => AppLocalizer.T("雾", "Fog"),
|
||||
51 or 53 or 55 => AppLocalizer.T("毛毛雨", "Drizzle"),
|
||||
56 or 57 => AppLocalizer.T("冻雨", "Freezing drizzle"),
|
||||
61 => AppLocalizer.T("小雨", "Light rain"),
|
||||
63 => AppLocalizer.T("中雨", "Moderate rain"),
|
||||
65 => AppLocalizer.T("大雨", "Heavy rain"),
|
||||
66 or 67 => AppLocalizer.T("冻雨", "Freezing rain"),
|
||||
71 => AppLocalizer.T("小雪", "Light snow"),
|
||||
73 => AppLocalizer.T("中雪", "Moderate snow"),
|
||||
75 => AppLocalizer.T("大雪", "Heavy snow"),
|
||||
77 => AppLocalizer.T("雪粒", "Snow grains"),
|
||||
80 => AppLocalizer.T("小阵雨", "Light showers"),
|
||||
81 => AppLocalizer.T("中阵雨", "Moderate showers"),
|
||||
82 => AppLocalizer.T("强阵雨", "Heavy showers"),
|
||||
85 => AppLocalizer.T("小阵雪", "Light snow showers"),
|
||||
86 => AppLocalizer.T("强阵雪", "Heavy snow showers"),
|
||||
95 => AppLocalizer.T("雷雨", "Thunderstorm"),
|
||||
96 or 99 => AppLocalizer.T("强雷雨", "Thunderstorm with hail"),
|
||||
_ => AppLocalizer.T("多云", "Weather")
|
||||
};
|
||||
|
||||
private static string WeatherGlyph(int code) => code switch
|
||||
{
|
||||
0 => "\uE706",
|
||||
1 or 2 or 3 => "\uE753",
|
||||
45 or 48 => "\uE9D2",
|
||||
>= 71 and <= 77 => "\uE9CC",
|
||||
85 or 86 => "\uE9CC",
|
||||
>= 51 and <= 67 => "\uE814",
|
||||
>= 80 and <= 82 => "\uE814",
|
||||
>= 95 and <= 99 => "\uE945",
|
||||
_ => "\uE753"
|
||||
};
|
||||
|
||||
private static WeatherVisualKind VisualKind(int code) => code switch
|
||||
{
|
||||
0 => WeatherVisualKind.Clear,
|
||||
1 or 2 => WeatherVisualKind.PartlyCloudy,
|
||||
3 => WeatherVisualKind.Cloudy,
|
||||
45 or 48 => WeatherVisualKind.Fog,
|
||||
51 or 53 or 55 => WeatherVisualKind.Drizzle,
|
||||
56 or 57 or 66 or 67 => WeatherVisualKind.FreezingRain,
|
||||
61 or 63 or 65 => WeatherVisualKind.Rain,
|
||||
71 or 73 or 75 => WeatherVisualKind.Snow,
|
||||
77 => WeatherVisualKind.SnowGrains,
|
||||
80 or 81 or 82 => WeatherVisualKind.Showers,
|
||||
85 or 86 => WeatherVisualKind.SnowShowers,
|
||||
95 or 96 or 99 => WeatherVisualKind.Thunderstorm,
|
||||
_ => WeatherVisualKind.Unknown
|
||||
};
|
||||
|
||||
private static WeatherIntensity Intensity(int code) => code switch
|
||||
{
|
||||
51 or 56 or 61 or 66 or 71 or 80 or 85 => WeatherIntensity.Light,
|
||||
53 or 63 or 73 or 81 or 95 => WeatherIntensity.Moderate,
|
||||
55 or 57 or 65 or 67 or 75 or 77 or 82 or 86 or 96 or 99 => WeatherIntensity.Heavy,
|
||||
_ => WeatherIntensity.None
|
||||
};
|
||||
|
||||
private static double? GetDouble(JsonElement element, string name)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when property.TryGetDouble(out var value) => value,
|
||||
JsonValueKind.String when double.TryParse(property.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) => value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static double? GetFirstArrayDouble(JsonElement element, string name)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(name, out var property) ||
|
||||
property.ValueKind != JsonValueKind.Array ||
|
||||
property.GetArrayLength() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var first = property[0];
|
||||
return first.ValueKind == JsonValueKind.Number && first.TryGetDouble(out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string name)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when property.TryGetInt32(out var value) => value,
|
||||
JsonValueKind.String when int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) => value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static long? GetLong(JsonElement element, string name)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(name, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when property.TryGetInt64(out var value) => value,
|
||||
JsonValueKind.String when long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) => value,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeLocationName(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double radiusKm = 6371.0;
|
||||
var dLat = DegreesToRadians(lat2 - lat1);
|
||||
var dLon = DegreesToRadians(lon2 - lon1);
|
||||
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
||||
Math.Cos(DegreesToRadians(lat1)) * Math.Cos(DegreesToRadians(lat2)) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
return radiusKm * c;
|
||||
}
|
||||
|
||||
private static double DegreesToRadians(double degrees) => degrees * Math.PI / 180;
|
||||
|
||||
private Task WriteLogAsync(string level, string category, string message, string? detail = null)
|
||||
{
|
||||
return logService?.WriteAsync(level, category, message, detail) ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record WeatherLocation(
|
||||
string DisplayDistrict,
|
||||
string QueryDistrict,
|
||||
long? DistrictGeoNameId,
|
||||
string DisplayCity,
|
||||
string QueryCity,
|
||||
long? CityGeoNameId,
|
||||
string DisplayRegion,
|
||||
string QueryRegion,
|
||||
string DisplayCountry,
|
||||
string QueryCountry,
|
||||
string CountryCode,
|
||||
double Latitude,
|
||||
double Longitude);
|
||||
|
||||
private sealed record WeatherPlace(string DisplayName, string QueryLevel, double Latitude, double Longitude);
|
||||
|
||||
private sealed record GeocodedPlace(string Name, string CountryCode, double Latitude, double Longitude);
|
||||
|
||||
private sealed record IpLocation(
|
||||
string City,
|
||||
string Region,
|
||||
string Country,
|
||||
string CountryCode,
|
||||
double Latitude,
|
||||
double Longitude);
|
||||
|
||||
private sealed record ClientLocation(
|
||||
string? Locality,
|
||||
string? City,
|
||||
string? PrincipalSubdivision,
|
||||
string? CountryName,
|
||||
string? CountryCode,
|
||||
double? Latitude,
|
||||
double? Longitude,
|
||||
IReadOnlyList<AdministrativeArea> Administrative)
|
||||
{
|
||||
public static ClientLocation Empty { get; } = new(null, null, null, null, null, null, null, []);
|
||||
}
|
||||
|
||||
private sealed record AdministrativeArea(
|
||||
string Name,
|
||||
string? IsoName,
|
||||
int? AdminLevel,
|
||||
int? Order,
|
||||
long? GeoNameId,
|
||||
string? IsoCode);
|
||||
}
|
||||
Reference in New Issue
Block a user