import 'dart:convert'; import 'package:http/http.dart' as http; import '../../domain/waypoint/flat_waypoint_list.dart'; import '../../domain/wind/wind_math.dart'; /// Kombinierte Stuetzpunkte fuer die Windinterpolation an einem Ort, plus die /// dort zugrunde liegende Bodenhoehe (ASL) - analog buildCombinedWindLevels() /// im HTML-Demonstrator (Doku 3.8). class WindCombinedLevels { const WindCombinedLevels({required this.levels, required this.elevationM}); final List levels; final double elevationM; } /// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte, /// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines /// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator /// Modellauswahl-Fehler (Doku 4.10). class WindService { WindService({http.Client? client}) : _client = client ?? http.Client(); final http.Client _client; static const _pressureLevels = [1000, 975, 950, 925, 900, 850, 800, 750, 700]; static const _fixedHeights = [10, 80, 120, 180]; Uri _pressureUri(String lats, String lons) { final vars = _pressureLevels .map((p) => 'wind_speed_${p}hPa,wind_direction_${p}hPa,geopotential_height_${p}hPa') .join(','); return Uri.https('api.open-meteo.com', '/v1/forecast', { 'latitude': lats, 'longitude': lons, 'hourly': vars, 'wind_speed_unit': 'ms', 'timezone': 'UTC', 'forecast_days': '1', }); } Uri _fixedUri(String lats, String lons) { final vars = '${_fixedHeights.map((h) => 'wind_speed_${h}m,wind_direction_${h}m').join(',')},wind_gusts_10m'; return Uri.https('api.open-meteo.com', '/v1/forecast', { 'latitude': lats, 'longitude': lons, 'hourly': vars, 'wind_speed_unit': 'ms', 'timezone': 'UTC', 'forecast_days': '1', }); } Future _fetchJson(Uri uri) async { try { final response = await _client.get(uri).timeout(const Duration(seconds: 8)); if (response.statusCode != 200) return null; return jsonDecode(response.body); } catch (_) { return null; } } /// Open-Meteo liefert bei einem einzelnen Ort ein Objekt, bei mehreren /// kommagetrennten Koordinaten ein Array - hier auf eine einheitliche /// Liste fester Laenge normalisiert. List?> _asResultList(dynamic decoded, int expectedCount) { if (decoded is List) { return List.generate( expectedCount, (i) => i < decoded.length ? decoded[i] as Map? : null, ); } if (decoded is Map) { return [decoded, ...List?>.filled(expectedCount - 1, null)]; } return List?>.filled(expectedCount, null); } int _nearestHourIndex(List time) { final now = DateTime.now().toUtc(); var bestIdx = 0; var bestDiffMs = double.infinity; for (var i = 0; i < time.length; i++) { final t = DateTime.parse('${time[i]}:00Z'); final diffMs = t.difference(now).inMilliseconds.abs().toDouble(); if (diffMs < bestDiffMs) { bestDiffMs = diffMs; bestIdx = i; } } return bestIdx; } /// Vereint Druckflaechen- und feste Nabenhoehen-Daten zu einer /// gemeinsamen Stuetzpunktliste (AGL-Hoehen der Nabenhoehen werden dazu in /// ASL umgerechnet). Entweder Quelle darf fehlen (null); genutzt wird, was /// vorhanden ist - analog buildCombinedWindLevels() im HTML-Demonstrator. WindCombinedLevels buildCombinedWindLevels( Map? pressureRes, Map? fixedRes, ) { final levels = []; var elevation = 0.0; final pHourly = pressureRes?['hourly'] as Map?; final pTime = pHourly?['time'] as List?; if (pHourly != null && pTime != null && pTime.isNotEmpty) { final idx = _nearestHourIndex(pTime); elevation = (pressureRes?['elevation'] as num?)?.toDouble() ?? 0.0; for (final p in _pressureLevels) { final h = (pHourly['geopotential_height_${p}hPa'] as List?)?[idx]; final s = (pHourly['wind_speed_${p}hPa'] as List?)?[idx]; final d = (pHourly['wind_direction_${p}hPa'] as List?)?[idx]; if (h is num && s is num && d is num && h >= elevation) { levels.add(WindLevel( heightAmslM: h.toDouble(), speedMs: s.toDouble(), dirFromDeg: d.toDouble(), )); } } } final fHourly = fixedRes?['hourly'] as Map?; final fTime = fHourly?['time'] as List?; if (fHourly != null && fTime != null && fTime.isNotEmpty) { final idx = _nearestHourIndex(fTime); if (elevation == 0.0) { elevation = (fixedRes?['elevation'] as num?)?.toDouble() ?? 0.0; } for (final h in _fixedHeights) { final s = (fHourly['wind_speed_${h}m'] as List?)?[idx]; final d = (fHourly['wind_direction_${h}m'] as List?)?[idx]; if (s is num && d is num) { levels.add(WindLevel( heightAmslM: elevation + h, speedMs: s.toDouble(), dirFromDeg: d.toDouble(), )); } } } return WindCombinedLevels(levels: levels, elevationM: elevation); } /// Wind, interpoliert auf 120 m AGL ueber dem Referenzort (erster /// Wegpunkt, sonst Kartenzentrum) - fuer die Kopfleisten-Pille. Future fetchHeaderWind(double lat, double lon) async { final results = await Future.wait([ _fetchJson(_pressureUri(lat.toStringAsFixed(5), lon.toStringAsFixed(5))), _fetchJson(_fixedUri(lat.toStringAsFixed(5), lon.toStringAsFixed(5))), ]); final pressureRes = _asResultList(results[0], 1)[0]; final fixedRes = _asResultList(results[1], 1)[0]; final combined = buildCombinedWindLevels(pressureRes, fixedRes); if (combined.levels.isEmpty) return null; return interpolateWindAtHeight(combined.levels, combined.elevationM + 120); } /// Holt Wind fuer alle Wegpunkte in einem gebuendelten Request (Open-Meteo /// akzeptiert kommagetrennte Koordinatenlisten) und interpoliert jeweils /// auf die tatsaechliche Zielhoehe (Bodenhoehe + Soll-Altitude) - analog /// fetchWindForWaypoints() im HTML-Demonstrator. Future> fetchWindForWaypoints(List waypoints) async { if (waypoints.isEmpty) return waypoints; final lats = waypoints.map((w) => w.lat.toStringAsFixed(5)).join(','); final lons = waypoints.map((w) => w.lon.toStringAsFixed(5)).join(','); final results = await Future.wait([ _fetchJson(_pressureUri(lats, lons)), _fetchJson(_fixedUri(lats, lons)), ]); final pressureResults = _asResultList(results[0], waypoints.length); final fixedResults = _asResultList(results[1], waypoints.length); return [ for (var i = 0; i < waypoints.length; i++) _applyWind(waypoints[i], pressureResults[i], fixedResults[i]), ]; } Waypoint _applyWind( Waypoint wp, Map? pressureRes, Map? fixedRes, ) { final combined = buildCombinedWindLevels(pressureRes, fixedRes); if (combined.levels.isEmpty) return wp; final result = interpolateWindAtHeight( combined.levels, combined.elevationM + wp.altitudeM, ); if (result == null) return wp; return wp.copyWith( windSpeedMs: result.speedMs, windDirFromDeg: result.dirFromDeg, windElevationM: combined.elevationM, ); } }