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 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 TimeSpan WeatherOperationTimeout = TimeSpan.FromSeconds(12); private static readonly HttpRequestPolicy WeatherRequestPolicy = new( TimeSpan.FromSeconds(7), MaxRetries: 0, CacheMode: HttpCacheMode.UseProtocol); 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 GetCurrentAsync(CancellationToken cancellationToken = default) { using var operationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); operationCts.CancelAfter(WeatherOperationTimeout); var requestToken = operationCts.Token; try { var location = await ResolveLocationAsync(requestToken).ConfigureAwait(false); var weatherPlace = await ResolveWeatherPlaceAsync(location, requestToken).ConfigureAwait(false); var uri = BuildForecastUri(weatherPlace); var forecast = await GetWeatherStringAsync(uri, requestToken).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 (IsRecoverableWeatherFailure(exception)) { var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180); await WriteLogAsync("Warning", "weather", "Title weather unavailable", safe).ConfigureAwait(false); return TitleWeatherSnapshot.Offline(safe); } } private async Task 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 TryReadClientLocationAsync(Uri uri, CancellationToken cancellationToken) { try { var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false); return ParseClientLocation(content); } catch (Exception exception) when (IsRecoverableWeatherFailure(exception)) { return null; } } private async Task TryResolveIpApiLocationAsync(CancellationToken cancellationToken) { try { var content = await GetWeatherStringAsync(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 (IsRecoverableWeatherFailure(exception)) { return null; } } private Task ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(new WeatherPlace( FormatDisplayLocation(location, preferDistrict: true), AppLocalizer.T("经纬度", "Coordinates"), location.Latitude, location.Longitude)); } private async Task 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 GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false); using var document = JsonDocument.Parse(content); return TryParseGeocodedPlace(document.RootElement, out var place) ? place : null; } catch (Exception exception) when (IsRecoverableWeatherFailure(exception)) { return null; } } private async Task 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 GetWeatherStringAsync(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 (IsRecoverableWeatherFailure(exception)) { return null; } } private async Task GetWeatherStringAsync(Uri uri, CancellationToken cancellationToken) { var response = await httpService.SendAsync( uri, policy: WeatherRequestPolicy, cancellationToken: cancellationToken).ConfigureAwait(false); return response.Content; } private static bool IsRecoverableWeatherFailure(Exception exception) => exception is HttpRequestException or HttpRequestTimeoutException or TimeoutException or OperationCanceledException or JsonException or InvalidOperationException or FormatException or IOException; 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(); 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 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 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 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); }