diff --git a/app/lib/domain/waypoint/flat_waypoint_list.dart b/app/lib/domain/waypoint/flat_waypoint_list.dart index 19efd06..c2cacd2 100644 --- a/app/lib/domain/waypoint/flat_waypoint_list.dart +++ b/app/lib/domain/waypoint/flat_waypoint_list.dart @@ -15,6 +15,9 @@ class Waypoint { this.action = WaypointAction.none, this.jumpTargetIndex, this.jumpRepeatCount, + this.windSpeedMs, + this.windDirFromDeg, + this.windElevationM, }); final double lat; @@ -30,6 +33,19 @@ class Waypoint { /// Nur bei action == jump gesetzt; null bedeutet Endlos-Wiederholung. final int? jumpRepeatCount; + /// Auf Hoehe altitudeM interpolierte Windgeschwindigkeit (Doku 3.8), null + /// bis "Fetch wind" ausgefuehrt wurde. Reine Planungshilfe, nicht Teil des + /// protokollneutralen Missionsinhalts - der Encoder liest diese Felder nie. + final double? windSpeedMs; + + /// Windrichtung, aus der der Wind weht (Grad, 0=Nord), analog windSpeedMs. + final double? windDirFromDeg; + + /// Bodenhoehe (ASL) am Wegpunkt, wie von der Wind-API geliefert - noetig, + /// um windSpeedMs/windDirFromDeg spaeter neu zu interpolieren oder + /// anzuzeigen (z.B. "interpoliert auf X m AMSL"). + final double? windElevationM; + Waypoint copyWith({ double? lat, double? lon, @@ -39,6 +55,9 @@ class Waypoint { WaypointAction? action, int? jumpTargetIndex, int? jumpRepeatCount, + double? windSpeedMs, + double? windDirFromDeg, + double? windElevationM, }) { return Waypoint( lat: lat ?? this.lat, @@ -49,6 +68,9 @@ class Waypoint { action: action ?? this.action, jumpTargetIndex: jumpTargetIndex ?? this.jumpTargetIndex, jumpRepeatCount: jumpRepeatCount ?? this.jumpRepeatCount, + windSpeedMs: windSpeedMs ?? this.windSpeedMs, + windDirFromDeg: windDirFromDeg ?? this.windDirFromDeg, + windElevationM: windElevationM ?? this.windElevationM, ); } } diff --git a/app/lib/domain/wind/wind_math.dart b/app/lib/domain/wind/wind_math.dart new file mode 100644 index 0000000..92fe0db --- /dev/null +++ b/app/lib/domain/wind/wind_math.dart @@ -0,0 +1,130 @@ +import 'dart:math' as math; + +import '../waypoint/flat_waypoint_list.dart'; + +/// Ein Stuetzpunkt fuer die Windinterpolation: Windgeschwindigkeit/-richtung +/// in einer bestimmten Hoehe ueber dem Meeresspiegel (AMSL). Kombiniert aus +/// Druckflaechen- und festen Nabenhoehen-Daten (Doku 3.8, +/// buildCombinedWindLevels() im HTML-Demonstrator). +class WindLevel { + const WindLevel({ + required this.heightAmslM, + required this.speedMs, + required this.dirFromDeg, + }); + + final double heightAmslM; + final double speedMs; + final double dirFromDeg; +} + +/// Interpoliertes Windergebnis an einer bestimmten Zielhoehe. +class WindSample { + const WindSample({required this.speedMs, required this.dirFromDeg}); + + final double speedMs; + final double dirFromDeg; +} + +/// Kreisfoermige Interpolation zwischen zwei Windrichtungen (kuerzester Weg +/// ueber die 0/360-Grenze), analog interpolateAngle() im HTML-Demonstrator. +double interpolateAngle(double a, double b, double frac) { + final diff = ((b - a + 540) % 360) - 180; + return (a + diff * frac + 360) % 360; +} + +/// Lineare Interpolation zwischen den zwei Hoehen-Stuetzpunkten, die +/// [targetHeightAmslM] einschliessen; ausserhalb des Stuetzpunktbereichs wird +/// auf den jeweils naechstgelegenen Rand geklemmt (analog +/// interpolateWindAtHeight() im HTML-Demonstrator). Gibt null zurueck, wenn +/// keine Stuetzpunkte vorhanden sind. +WindSample? interpolateWindAtHeight( + List levels, + double targetHeightAmslM, +) { + if (levels.isEmpty) return null; + final sorted = [...levels] + ..sort((a, b) => a.heightAmslM.compareTo(b.heightAmslM)); + + final first = sorted.first; + if (targetHeightAmslM <= first.heightAmslM) { + return WindSample(speedMs: first.speedMs, dirFromDeg: first.dirFromDeg); + } + final last = sorted.last; + if (targetHeightAmslM >= last.heightAmslM) { + return WindSample(speedMs: last.speedMs, dirFromDeg: last.dirFromDeg); + } + for (var i = 0; i < sorted.length - 1; i++) { + final a = sorted[i]; + final b = sorted[i + 1]; + if (targetHeightAmslM >= a.heightAmslM && + targetHeightAmslM <= b.heightAmslM) { + final span = b.heightAmslM - a.heightAmslM; + final frac = span == 0 ? 0.0 : (targetHeightAmslM - a.heightAmslM) / span; + return WindSample( + speedMs: a.speedMs + (b.speedMs - a.speedMs) * frac, + dirFromDeg: interpolateAngle(a.dirFromDeg, b.dirFromDeg, frac), + ); + } + } + return WindSample(speedMs: last.speedMs, dirFromDeg: last.dirFromDeg); +} + +/// Grosskreis-Peilung von (lat1,lon1) nach (lat2,lon2) in Grad (0=Nord), +/// analog bearingBetween() im HTML-Demonstrator. +double bearingBetweenDeg(double lat1, double lon1, double lat2, double lon2) { + final lat1Rad = lat1 * math.pi / 180; + final lat2Rad = lat2 * math.pi / 180; + final dLon = (lon2 - lon1) * math.pi / 180; + final y = math.sin(dLon) * math.cos(lat2Rad); + final x = math.cos(lat1Rad) * math.sin(lat2Rad) - + math.sin(lat1Rad) * math.cos(lat2Rad) * math.cos(dLon); + return (math.atan2(y, x) * 180 / math.pi + 360) % 360; +} + +/// Rueckenwind-Komponente (positiv) bzw. Gegenwind-Komponente (negativ) +/// entlang eines Kurses, analog headTailwindComponent() im HTML- +/// Demonstrator: Wind, der genau von hinten kommt, ergibt +windSpeedMs. +double headTailwindComponent( + double bearingDeg, + double windSpeedMs, + double windDirFromDeg, +) { + final windToward = (windDirFromDeg + 180) % 360; + final diff = (windToward - bearingDeg) * math.pi / 180; + return windSpeedMs * math.cos(diff); +} + +/// Windkomponente auf dem Leg von [a] nach [b], interpoliert aus dem am +/// Zielwegpunkt [b] hinterlegten Wind (analog computeLegWindComponent() im +/// HTML-Demonstrator - der Zielpunkt bestimmt den massgeblichen Wind fuer +/// dieses Leg). Null, solange fuer [b] noch kein Wind geladen wurde. +double? computeLegWindComponent(Waypoint a, Waypoint b) { + final windSpeedMs = b.windSpeedMs; + final windDirFromDeg = b.windDirFromDeg; + if (windSpeedMs == null || windDirFromDeg == null) return null; + final bearing = bearingBetweenDeg(a.lat, a.lon, b.lat, b.lon); + return headTailwindComponent(bearing, windSpeedMs, windDirFromDeg); +} + +/// Windkomponente pro Leg der gesamten Route (Laenge waypoints.length - 1). +List computeLegWind(List waypoints) { + return [ + for (var i = 0; i < waypoints.length - 1; i++) + computeLegWindComponent(waypoints[i], waypoints[i + 1]), + ]; +} + +/// Erreichbare Bodengeschwindigkeit pro Leg (Soll-Geschwindigkeit des +/// Zielwegpunkts + Windkomponente), null wo noch kein Wind geladen ist - +/// analog der groundSpeeds-Berechnung in buildFullChart() im HTML- +/// Demonstrator. +List computeGroundSpeeds(List waypoints) { + return [ + for (var i = 0; i < waypoints.length - 1; i++) + switch (computeLegWindComponent(waypoints[i], waypoints[i + 1])) { + final wind? => waypoints[i + 1].speedMs + wind, + null => null, + }, + ]; +} diff --git a/app/lib/services/wind/wind_service.dart b/app/lib/services/wind/wind_service.dart new file mode 100644 index 0000000..1585557 --- /dev/null +++ b/app/lib/services/wind/wind_service.dart @@ -0,0 +1,358 @@ +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; +} + +class WindValidationFixedReading { + const WindValidationFixedReading({ + required this.heightAglM, + this.speedMs, + this.dirFromDeg, + }); + + final int heightAglM; + final double? speedMs; + final double? dirFromDeg; +} + +class WindValidationPressureReading { + const WindValidationPressureReading({ + required this.pressureHpa, + this.heightAmslM, + this.speedMs, + this.dirFromDeg, + required this.excluded, + }); + + final int pressureHpa; + final double? heightAmslM; + final double? speedMs; + final double? dirFromDeg; + + /// True, wenn dieser Stuetzpunkt unvollstaendig ist oder unterhalb der + /// Bodenhoehe liegt und daher von der Interpolation ausgeschlossen wurde. + final bool excluded; +} + +/// Ergebnis des Wind-Validierungspanels (Doku 3.8): rohe Messwerte auf +/// festen Nabenhoehen und Druckflaechen, damit nachvollziehbar bleibt, wie +/// der interpolierte Wert an [interpolatedAt120] zustande kam. +class WindValidationResult { + const WindValidationResult({ + required this.lat, + required this.lon, + required this.elevationM, + required this.fixedRequestFailed, + required this.fixedReadings, + this.gustMs, + required this.pressureRequestFailed, + required this.pressureReadings, + this.interpolatedAt120, + }); + + final double lat; + final double lon; + final double elevationM; + final bool fixedRequestFailed; + final List fixedReadings; + final double? gustMs; + final bool pressureRequestFailed; + final List pressureReadings; + final WindSample? interpolatedAt120; +} + +/// 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, + ); + } + + /// Detaillierte Rohdaten fuer das Wind-Validierungspanel: einzelne + /// Messwerte je Nabenhoehe/Druckflaeche, damit nachvollziehbar bleibt, wie + /// der interpolierte 120-m-Wert zustande kam (Doku 3.8/4.9: Boeen nur + /// hier, nicht in der Wegpunktanzeige). + Future fetchValidation(double lat, double lon) async { + final lats = lat.toStringAsFixed(5); + final lons = lon.toStringAsFixed(5); + final results = await Future.wait([ + _fetchJson(_fixedUri(lats, lons)), + _fetchJson(_pressureUri(lats, lons)), + ]); + final fixedRes = _asResultList(results[0], 1)[0]; + final pressureRes = _asResultList(results[1], 1)[0]; + + final elevation = ((fixedRes?['elevation'] as num?) ?? + (pressureRes?['elevation'] as num?)) + ?.toDouble() ?? + 0.0; + + var fixedReadings = []; + double? gust; + final fHourly = fixedRes?['hourly'] as Map?; + final fTime = fHourly?['time'] as List?; + final fixedFailed = fHourly == null || fTime == null || fTime.isEmpty; + if (!fixedFailed) { + final idx = _nearestHourIndex(fTime); + fixedReadings = [ + for (final h in _fixedHeights) + WindValidationFixedReading( + heightAglM: h, + speedMs: ((fHourly['wind_speed_${h}m'] as List?)?[idx] as num?) + ?.toDouble(), + dirFromDeg: ((fHourly['wind_direction_${h}m'] as List?)?[idx] as num?) + ?.toDouble(), + ), + ]; + gust = ((fHourly['wind_gusts_10m'] as List?)?[idx] as num?)?.toDouble(); + } + + var pressureReadings = []; + final pHourly = pressureRes?['hourly'] as Map?; + final pTime = pHourly?['time'] as List?; + final pressureFailed = pHourly == null || pTime == null || pTime.isEmpty; + if (!pressureFailed) { + final idx = _nearestHourIndex(pTime); + pressureReadings = [ + for (final p in _pressureLevels) + _pressureReadingAt(pHourly, idx, p, elevation), + ]; + } + + final combined = buildCombinedWindLevels(pressureRes, fixedRes); + final interpolated = combined.levels.isEmpty + ? null + : interpolateWindAtHeight(combined.levels, elevation + 120); + + return WindValidationResult( + lat: lat, + lon: lon, + elevationM: elevation, + fixedRequestFailed: fixedFailed, + fixedReadings: fixedReadings, + gustMs: gust, + pressureRequestFailed: pressureFailed, + pressureReadings: pressureReadings, + interpolatedAt120: interpolated, + ); + } + + WindValidationPressureReading _pressureReadingAt( + Map hourly, + int idx, + int pressureHpa, + double elevationM, + ) { + final h = (hourly['geopotential_height_${pressureHpa}hPa'] as List?)?[idx]; + final s = (hourly['wind_speed_${pressureHpa}hPa'] as List?)?[idx]; + final d = (hourly['wind_direction_${pressureHpa}hPa'] as List?)?[idx]; + final heightAmslM = h is num ? h.toDouble() : null; + final speedMs = s is num ? s.toDouble() : null; + final dirFromDeg = d is num ? d.toDouble() : null; + final excluded = heightAmslM == null || + speedMs == null || + dirFromDeg == null || + heightAmslM < elevationM; + return WindValidationPressureReading( + pressureHpa: pressureHpa, + heightAmslM: heightAmslM, + speedMs: speedMs, + dirFromDeg: dirFromDeg, + excluded: excluded, + ); + } +} diff --git a/app/lib/ui/providers/current_mission_provider.dart b/app/lib/ui/providers/current_mission_provider.dart index 2d6c013..ae073d1 100644 --- a/app/lib/ui/providers/current_mission_provider.dart +++ b/app/lib/ui/providers/current_mission_provider.dart @@ -14,6 +14,13 @@ class CurrentMissionNotifier extends Notifier> { state = [...state, waypoint]; } + /// Ersetzt die gesamte Liste (z.B. nach "Fetch wind", das alle Wegpunkte + /// gebuendelt in einem Schritt aktualisiert - anders als die uebrigen + /// Setter hier, die genau einen Wegpunkt betreffen). + void replaceAll(List waypoints) { + state = waypoints; + } + /// Aktualisiert die Position eines Wegpunkts (Drag-to-Reposition im /// Editier-Modus: Kartenverschieben bewegt den Zielpunkt live mit). void moveWaypoint(int index, double lat, double lon) { diff --git a/app/lib/ui/providers/wind_provider.dart b/app/lib/ui/providers/wind_provider.dart new file mode 100644 index 0000000..c0ca276 --- /dev/null +++ b/app/lib/ui/providers/wind_provider.dart @@ -0,0 +1,91 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/wind/wind_math.dart'; +import '../../services/wind/wind_service.dart'; +import 'current_mission_provider.dart'; +import 'map_controller_provider.dart'; + +final windServiceProvider = Provider((ref) => WindService()); + +/// Zustand der Kopfleisten-Wind-Pille (Doku 3.8). +class HeaderWindState { + const HeaderWindState({ + this.sample, + this.loading = false, + this.showPerWaypoint = false, + }); + + final WindSample? sample; + final bool loading; + + /// Umschaltbar per Antippen der Pille (HTML-Demonstrator: + /// showPerWaypointWind) - blendet Wind-Marker an jedem Wegpunkt ein/aus. + final bool showPerWaypoint; + + HeaderWindState copyWith({ + WindSample? sample, + bool? loading, + bool? showPerWaypoint, + }) { + return HeaderWindState( + sample: sample ?? this.sample, + loading: loading ?? this.loading, + showPerWaypoint: showPerWaypoint ?? this.showPerWaypoint, + ); + } +} + +/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem +/// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog +/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator. +class HeaderWindNotifier extends Notifier { + @override + HeaderWindState build() => const HeaderWindState(); + + Future refresh() async { + final waypoints = ref.read(currentMissionProvider); + (double, double)? location; + if (waypoints.isNotEmpty) { + location = (waypoints.first.lat, waypoints.first.lon); + } else { + try { + final center = ref.read(mapControllerProvider).camera.center; + location = (center.latitude, center.longitude); + } catch (_) { + // Kamera noch nicht bereit (Karte noch nicht gemountet/gelayoutet). + location = null; + } + } + if (location == null) return; + + state = state.copyWith(loading: true); + final sample = + await ref.read(windServiceProvider).fetchHeaderWind(location.$1, location.$2); + state = state.copyWith(loading: false, sample: sample); + } + + void toggleShowPerWaypoint() { + state = state.copyWith(showPerWaypoint: !state.showPerWaypoint); + } +} + +final headerWindProvider = + NotifierProvider(HeaderWindNotifier.new); + +/// Referenzort fuer Windabfragen ohne festen Bezugspunkt (Wind- +/// Validierungspanel aus der Wegpunktliste): erster Wegpunkt, sonst +/// aktuelles Kartenzentrum - analog headerWindLocation() im HTML- +/// Demonstrator. Null, wenn die Karte noch nicht gemountet/gelayoutet ist. +(double, double)? windReferenceLocation(WidgetRef ref) { + final waypoints = ref.read(currentMissionProvider); + if (waypoints.isNotEmpty) { + final first = waypoints.first; + return (first.lat, first.lon); + } + try { + final center = ref.read(mapControllerProvider).camera.center; + return (center.latitude, center.longitude); + } catch (_) { + return null; + } +} diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 6f12fd7..09d1104 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -11,6 +11,7 @@ import '../../../domain/mission/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../providers/current_mission_provider.dart'; import '../../providers/map_controller_provider.dart'; +import '../../providers/wind_provider.dart'; import '../../widgets/bottom_stats_bar.dart'; import '../../widgets/halo_menu.dart'; import '../../widgets/mini_altitude_profile.dart'; @@ -67,6 +68,11 @@ class _PlanScreenState extends ConsumerState { super.initState(); _mapController = ref.read(mapControllerProvider); _mapEventSub = _mapController.mapEventStream.listen(_onMapEvent); + // Erstbefuellung der Kopfleisten-Wind-Pille (Doku 3.8) - erst nach dem + // ersten Frame, da die Kartenkamera (Fallback-Referenzort ohne + // Wegpunkte) vor dem initialen Layout noch nicht bereit ist. + WidgetsBinding.instance + .addPostFrameCallback((_) => ref.read(headerWindProvider.notifier).refresh()); } @override @@ -163,6 +169,11 @@ class _PlanScreenState extends ConsumerState { catchRadiusM: DefaultDroneProfile.catchRadiusM, ), ); + if (waypoints.isEmpty) { + // Referenzort fuer die Wind-Pille wechselt vom Kartenzentrum auf + // den ersten Wegpunkt (Doku 3.8) - neu abfragen. + ref.read(headerWindProvider.notifier).refresh(); + } setState(() { _targetIndex = waypoints.length; _mode = _ReticleMode.snapped; diff --git a/app/lib/ui/widgets/full_value_chart.dart b/app/lib/ui/widgets/full_value_chart.dart index ea2678d..6940369 100644 --- a/app/lib/ui/widgets/full_value_chart.dart +++ b/app/lib/ui/widgets/full_value_chart.dart @@ -27,6 +27,8 @@ class FullValueChart extends StatefulWidget { required this.pxPerUnit, required this.unit, required this.onChanged, + this.legWind, + this.groundSpeeds, }); final List waypoints; @@ -41,6 +43,14 @@ class FullValueChart extends StatefulWidget { final String unit; final void Function(int index, double value) onChanged; + /// Kopf-/Rueckenwind-Komponente pro Leg (nur Speed-Tab, Doku 3.8) - + /// Laenge waypoints.length - 1, null wo noch kein Wind geladen ist. + final List? legWind; + + /// Erreichbare Bodengeschwindigkeit pro Leg (nur Speed-Tab), gleiche + /// Laenge/Null-Semantik wie [legWind]. + final List? groundSpeeds; + static const _distance = Distance(roundResult: false); static List cumulativeDistances(List waypoints) { @@ -66,9 +76,14 @@ class _FullValueChartState extends State { static const _hitToleranceX = 28.0; ({double lo, double hi}) _range() { - if (widget.values.isEmpty) return (lo: widget.min, hi: widget.max); - final dataMin = widget.values.reduce(math.min); - final dataMax = widget.values.reduce(math.max); + final allVals = [ + ...widget.values, + for (final v in widget.legWind ?? const []) ?v, + for (final v in widget.groundSpeeds ?? const []) ?v, + ]; + if (allVals.isEmpty) return (lo: widget.min, hi: widget.max); + final dataMin = allVals.reduce(math.min); + final dataMax = allVals.reduce(math.max); final span = dataMax - dataMin; final pad = math.max(span * 0.35, widget.step * 2); var lo = dataMin - pad; @@ -79,8 +94,11 @@ class _FullValueChartState extends State { lo = mid - minSpan / 2; hi = mid + minSpan / 2; } - if (lo < widget.min) lo = widget.min; - if (hi > widget.max) hi = widget.max; + // Nicht auf widget.min/max (gueltiger Drohnen-Geschwindigkeitsbereich) + // klemmen: Windkomponenten/Bodengeschwindigkeit (Doku 3.8) sind keine + // gueltigen Soll-Geschwindigkeiten und duerfen den sichtbaren Bereich + // durchaus unter min bzw. ueber max ziehen (HTML-Demonstrator: + // computeSpeedChartRange() klemmt ebenfalls nicht auf SPD_MIN/SPD_MAX). if (hi <= lo) { lo = widget.min; hi = widget.max; @@ -154,6 +172,8 @@ class _FullValueChartState extends State { lo: range.lo, hi: range.hi, unit: widget.unit, + legWind: widget.legWind, + groundSpeeds: widget.groundSpeeds, ), ), ); @@ -173,6 +193,8 @@ class _ChartPainter extends CustomPainter { required this.lo, required this.hi, required this.unit, + this.legWind, + this.groundSpeeds, }); final List waypoints; @@ -184,6 +206,8 @@ class _ChartPainter extends CustomPainter { final double lo; final double hi; final String unit; + final List? legWind; + final List? groundSpeeds; static const _padX = 50.0; static const _padY = 20.0; @@ -238,6 +262,8 @@ class _ChartPainter extends CustomPainter { v += step; } + _paintWindAndGroundSpeed(canvas, boxW, boxH); + // Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate). for (var i = 0; i < n - 1; i++) { final bad = i < legBad.length && legBad[i]; @@ -269,10 +295,136 @@ class _ChartPainter extends CustomPainter { } } + /// Wind-Diamanten je Leg (Kopf-/Rueckenwind-Komponente) und die daraus + /// erreichbare Bodengeschwindigkeit als gestrichelte Stufenlinie - nur auf + /// dem Speed-Tab genutzt (Doku 3.8, windHtml/groundSpeedHtml im HTML- + /// Demonstrator). Vor den eigentlichen Soll-Werte-Punkten gezeichnet, + /// damit diese immer obenauf liegen. + void _paintWindAndGroundSpeed(Canvas canvas, double boxW, double boxH) { + final wind = legWind; + if (wind != null && wind.any((v) => v != null)) { + final wZeroY = _yAt(0, boxH); + _drawDashedLine( + canvas, + Offset(_padX, wZeroY), + Offset(boxW - _padX, wZeroY), + Paint() + ..color = Colors.white.withValues(alpha: 0.4) + ..strokeWidth = 1.5, + dashWidth: 3, + gapWidth: 4, + ); + final zeroLabel = TextPainter( + text: TextSpan( + text: '0 (no wind effect on track)', + style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 10), + ), + textDirection: TextDirection.ltr, + )..layout(); + zeroLabel.paint(canvas, Offset(_padX, wZeroY - 5 - zeroLabel.height)); + + final windPts = []; + for (var i = 0; i < wind.length; i++) { + final w = wind[i]; + if (w == null) continue; + final mx = (_xAt(i, boxW) + _xAt(i + 1, boxW)) / 2; + final my = _yAt(w, boxH); + windPts.add(Offset(mx, my)); + + final isTail = w >= 0; + final color = isTail ? const Color(0xFF3ECF8E) : const Color(0xFFE2574A); + final diamond = Path() + ..moveTo(mx, my - 7) + ..lineTo(mx + 7, my) + ..lineTo(mx, my + 7) + ..lineTo(mx - 7, my) + ..close(); + canvas.drawPath(diamond, Paint()..color = color); + canvas.drawPath( + diamond, + Paint() + ..color = const Color(0xFF0C0C0C) + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + + final valueLabel = TextPainter( + text: TextSpan( + text: '${isTail ? '+' : ''}${w.toStringAsFixed(1)}', + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + )..layout(); + valueLabel.paint( + canvas, + Offset(mx - valueLabel.width / 2, my - 12 - valueLabel.height), + ); + } + + final connectPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..strokeWidth = 1; + for (var i = 0; i < windPts.length - 1; i++) { + _drawDashedLine(canvas, windPts[i], windPts[i + 1], connectPaint, + dashWidth: 2, gapWidth: 3); + } + } + + final ground = groundSpeeds; + if (ground != null) { + double? prevLevelY; + final groundPaint = Paint() + ..color = const Color(0xFF94BBE9).withValues(alpha: 0.55) + ..strokeWidth = 1.5; + for (var i = 0; i < ground.length; i++) { + final g = ground[i]; + if (g == null) { + prevLevelY = null; + continue; + } + final gy = _yAt(g, boxH); + final gx0 = _xAt(i, boxW); + final gx1 = _xAt(i + 1, boxW); + _drawDashedLine(canvas, Offset(gx0, gy), Offset(gx1, gy), groundPaint, + dashWidth: 5, gapWidth: 3); + if (prevLevelY != null) { + _drawDashedLine(canvas, Offset(gx0, prevLevelY), Offset(gx0, gy), + groundPaint, dashWidth: 5, gapWidth: 3); + } + prevLevelY = gy; + } + } + } + + void _drawDashedLine( + Canvas canvas, + Offset p1, + Offset p2, + Paint paint, { + double dashWidth = 4, + double gapWidth = 3, + }) { + final total = (p2 - p1).distance; + if (total == 0) return; + final direction = (p2 - p1) / total; + var traveled = 0.0; + while (traveled < total) { + final segEnd = math.min(traveled + dashWidth, total); + canvas.drawLine( + p1 + direction * traveled, + p1 + direction * segEnd, + paint, + ); + traveled += dashWidth + gapWidth; + } + } + @override bool shouldRepaint(covariant _ChartPainter oldDelegate) => oldDelegate.values != values || oldDelegate.activeIndex != activeIndex || oldDelegate.lo != lo || - oldDelegate.hi != hi; + oldDelegate.hi != hi || + oldDelegate.legWind != legWind || + oldDelegate.groundSpeeds != groundSpeeds; } diff --git a/app/lib/ui/widgets/header_wind_pill.dart b/app/lib/ui/widgets/header_wind_pill.dart new file mode 100644 index 0000000..8cd9faf --- /dev/null +++ b/app/lib/ui/widgets/header_wind_pill.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/wind_provider.dart'; +import 'wind_arrow_icon.dart'; + +/// Kopfleisten-Pille mit Windrichtung/-geschwindigkeit auf 120 m AGL ueber +/// dem Referenzort (Doku 3.8). Antippen schaltet die Wind-Marker an jedem +/// Wegpunkt ein/aus (HTML-Demonstrator: showPerWaypointWind). +class HeaderWindPill extends ConsumerWidget { + const HeaderWindPill({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(headerWindProvider); + final sample = state.sample; + final active = state.showPerWaypoint; + + return GestureDetector( + onTap: () => ref.read(headerWindProvider.notifier).toggleShowPerWaypoint(), + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: active + ? const Color(0xFF4D9DFF).withValues(alpha: 0.32) + : Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(17), + border: Border.all( + color: active + ? const Color(0xFF4D9DFF) + : Colors.white.withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (sample != null) ...[ + WindArrowIcon(dirFromDeg: sample.dirFromDeg, size: 15), + const SizedBox(width: 4), + ], + Text( + sample != null ? '${sample.speedMs.round()} m/s' : '—', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index 95d2ea2..cb1586c 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../app_mode/app_mode_cubit.dart'; import '../dmc_colors.dart'; +import 'header_wind_pill.dart'; import 'map_search_controls.dart'; /// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus @@ -10,11 +11,11 @@ import 'map_search_controls.dart'; /// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird /// als solider Button am Rand angeboten, um in ihn zu wechseln. /// -/// Traegt ausserhalb des Fly-Modus zusaetzlich die Ortssuche und die -/// Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #searchWrap/ -/// #homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer separaten -/// Leiste) - dafuer lebt der MapController im mapControllerProvider statt -/// als PlanScreen-privates Feld, siehe dort. +/// Traegt ausserhalb des Fly-Modus zusaetzlich die Wind-Pille, Ortssuche und +/// die Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #headerWindPill/ +/// #searchWrap/#homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer +/// separaten Leiste) - dafuer lebt der MapController im mapControllerProvider +/// statt als PlanScreen-privates Feld, siehe dort. class TopModeBar extends StatelessWidget { const TopModeBar({super.key}); @@ -50,7 +51,9 @@ class TopModeBar extends StatelessWidget { if (isFly) const Spacer() else ...[ - const SizedBox(width: 12), + const SizedBox(width: 8), + const HeaderWindPill(), + const SizedBox(width: 8), const Expanded(child: MapSearchControls()), const SizedBox(width: 12), ], diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index d8f76de..d6f3bb1 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -4,9 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/mission/default_drone_profile.dart'; import '../../domain/mission/route_geometry.dart'; import '../../domain/waypoint/flat_waypoint_list.dart'; +import '../../domain/wind/wind_math.dart'; import '../providers/current_mission_provider.dart'; +import '../providers/wind_provider.dart'; import 'full_value_chart.dart'; import 'waypoint_chip.dart'; +import 'wind_arrow_icon.dart'; +import 'wind_validate_panel.dart'; enum _PanelTab { list, altitude, speed } @@ -76,6 +80,39 @@ class _WaypointListPanelState extends ConsumerState { super.dispose(); } + /// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator: + /// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck. + Future _onFetchWind() async { + final waypoints = ref.read(currentMissionProvider); + final messenger = ScaffoldMessenger.of(context); + if (waypoints.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('No waypoints to fetch wind for')), + ); + return; + } + messenger.showSnackBar(const SnackBar(content: Text('Fetching wind…'))); + final updated = + await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints); + ref.read(currentMissionProvider.notifier).replaceAll(updated); + messenger.showSnackBar(const SnackBar(content: Text('Wind data updated'))); + } + + /// Oeffnet das Wind-Validierungspanel fuer den aktuellen Referenzort + /// (erster Wegpunkt, sonst Kartenzentrum - Doku 3.8). + void _onValidateWind() { + final location = windReferenceLocation(ref); + if (location == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No location available yet')), + ); + return; + } + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => WindValidatePanel(lat: location.$1, lon: location.$2), + )); + } + /// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das /// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das /// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht @@ -123,6 +160,19 @@ class _WaypointListPanelState extends ConsumerState { ), ), ), + _HeaderActionButton( + icon: Icons.air, + label: 'Fetch wind', + tint: const Color(0xFF4D9DFF), + onTap: _onFetchWind, + ), + const SizedBox(width: 8), + _HeaderActionButton( + icon: Icons.rule, + label: 'Validate', + tint: const Color(0xFFC8A04D), + onTap: _onValidateWind, + ), IconButton( onPressed: widget.onClose, icon: const Icon(Icons.close, color: Colors.white), @@ -162,6 +212,8 @@ class _WaypointListPanelState extends ConsumerState { pxPerUnit: 22, unit: ' m/s', onChanged: widget.onSpeedChanged, + legWind: computeLegWind(waypoints), + groundSpeeds: computeGroundSpeeds(waypoints), ), }, ), @@ -215,6 +267,8 @@ class _WaypointListPanelState extends ConsumerState { required double pxPerUnit, required String unit, required void Function(int index, double value) onChanged, + List? legWind, + List? groundSpeeds, }) { return Padding( padding: const EdgeInsets.fromLTRB(8, 16, 8, 8), @@ -230,6 +284,8 @@ class _WaypointListPanelState extends ConsumerState { pxPerUnit: pxPerUnit, unit: unit, onChanged: onChanged, + legWind: legWind, + groundSpeeds: groundSpeeds, ), ); } @@ -247,6 +303,7 @@ class _WaypointListPanelState extends ConsumerState { Expanded(child: Text('SPEED', style: _headerStyle)), Expanded(child: Text('EXECUTE', style: _headerStyle)), Expanded(child: Text('CATCH', style: _headerStyle)), + Expanded(child: Text('WIND', style: _headerStyle)), SizedBox(width: 76), ], ), @@ -375,6 +432,19 @@ class _WaypointListPanelState extends ConsumerState { : Text('${wp.catchRadiusM.round()} m', style: const TextStyle(color: Colors.white)), ), + Expanded( + child: wp.windSpeedMs != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + WindArrowIcon(dirFromDeg: wp.windDirFromDeg!, size: 13), + const SizedBox(width: 4), + Text('${wp.windSpeedMs!.round()} m/s', + style: const TextStyle(color: Colors.white)), + ], + ) + : const Text('—', style: TextStyle(color: Colors.white38)), + ), IconButton( onPressed: () => setState( () => _rowEditIndex = isEditing ? -1 : index, @@ -405,3 +475,49 @@ class _WaypointListPanelState extends ConsumerState { _ => '—', }; } + +/// Kleiner getoenter Aktions-Button im Panel-Header (HTML-Demonstrator: +/// #windFetchBtn/#windValidateBtn). +class _HeaderActionButton extends StatelessWidget { + const _HeaderActionButton({ + required this.icon, + required this.label, + required this.tint, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color tint; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: tint.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: tint.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Colors.white), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/wind_arrow_icon.dart b/app/lib/ui/widgets/wind_arrow_icon.dart new file mode 100644 index 0000000..e6d2c1a --- /dev/null +++ b/app/lib/ui/widgets/wind_arrow_icon.dart @@ -0,0 +1,28 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Windpfeil, der in die Richtung zeigt, in die der Wind weht (nicht woher +/// er kommt) - analog windArrowSvg() im HTML-Demonstrator, dort ebenfalls +/// um (dirFrom + 180) Grad gedreht. +class WindArrowIcon extends StatelessWidget { + const WindArrowIcon({ + super.key, + required this.dirFromDeg, + this.size = 14, + this.color = Colors.white, + }); + + final double dirFromDeg; + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + final towardDeg = (dirFromDeg + 180) % 360; + return Transform.rotate( + angle: towardDeg * math.pi / 180, + child: Icon(Icons.navigation, size: size, color: color), + ); + } +} diff --git a/app/lib/ui/widgets/wind_validate_panel.dart b/app/lib/ui/widgets/wind_validate_panel.dart new file mode 100644 index 0000000..b30afc9 --- /dev/null +++ b/app/lib/ui/widgets/wind_validate_panel.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/wind/wind_service.dart'; +import '../providers/wind_provider.dart'; + +/// Zeigt die Rohdaten hinter dem interpolierten Wind an einem Ort: +/// gemessene Werte je fester Nabenhoehe (inkl. Boeen) und je Druckflaeche, +/// sowie das daraus interpolierte Ergebnis auf 120 m AGL (Doku 3.8/4.9: +/// Boeen nur hier, nicht in der Wegpunktanzeige). Fullscreen-Route wie +/// WaypointListPanel (HTML-Demonstrator: #windValidatePanel, ebenfalls +/// .panel). +class WindValidatePanel extends ConsumerStatefulWidget { + const WindValidatePanel({super.key, required this.lat, required this.lon}); + + final double lat; + final double lon; + + @override + ConsumerState createState() => _WindValidatePanelState(); +} + +class _WindValidatePanelState extends ConsumerState { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = ref + .read(windServiceProvider) + .fetchValidation(widget.lat, widget.lon); + } + + static const _labelStyle = TextStyle(color: Colors.white, fontSize: 14, height: 1.6); + static const _errorStyle = TextStyle(color: Color(0xFFFF8A7A), fontSize: 14, height: 1.6); + static const _excludedStyle = TextStyle( + color: Colors.white38, + fontSize: 14, + height: 1.6, + decoration: TextDecoration.lineThrough, + ); + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xEB080808), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Row( + children: [ + const Expanded( + child: Text( + 'Wind Validation', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF4D9DFF)), + ); + } + return _body(snapshot.data!); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _body(WindValidationResult result) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Location: ${result.lat.toStringAsFixed(5)}, ${result.lon.toStringAsFixed(5)} ' + '· ground elevation ${result.elevationM.round()} m ASL', + style: _labelStyle, + ), + const SizedBox(height: 12), + const Text('Fixed hub heights', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)), + const SizedBox(height: 4), + if (result.fixedRequestFailed) + const Text('Request failed', style: _errorStyle) + else ...[ + for (final r in result.fixedReadings) + Text( + '${r.heightAglM} m AGL: ' + '${r.speedMs != null ? '${r.speedMs!.toStringAsFixed(1)} m/s' : 'n/a'}' + '${r.dirFromDeg != null ? ', ${r.dirFromDeg!.round()}°' : ''}', + style: _labelStyle, + ), + Text( + 'Gusts (10 m AGL): ${result.gustMs != null ? '${result.gustMs!.toStringAsFixed(1)} m/s' : 'n/a'}', + style: _labelStyle, + ), + ], + const SizedBox(height: 16), + const Text('Pressure levels', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)), + const Text( + 'discarded if below ground or incomplete', + style: TextStyle(color: Colors.white38, fontSize: 12), + ), + const SizedBox(height: 4), + if (result.pressureRequestFailed) + const Text('Request failed', style: _errorStyle) + else + for (final r in result.pressureReadings) + Text( + '${r.pressureHpa} hPa: ' + '${r.heightAmslM != null ? '${r.heightAmslM!.round()} m ASL' : '?'}, ' + '${r.speedMs != null ? '${r.speedMs!.toStringAsFixed(1)} m/s' : 'n/a'}' + '${r.excluded ? (r.heightAmslM != null && r.heightAmslM! < result.elevationM ? ' (below ground, excluded)' : ' (incomplete, excluded)') : ''}', + style: r.excluded ? _excludedStyle : _labelStyle, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.only(top: 12), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Colors.white24)), + ), + child: Text( + 'Interpolated & referenced at 120 m AGL ' + '(= ${(result.elevationM + 120).round()} m ASL, using pressure levels + fixed heights combined): ' + '${result.interpolatedAt120 != null ? '${result.interpolatedAt120!.speedMs.toStringAsFixed(1)} m/s, ${result.interpolatedAt120!.dirFromDeg.round()}°' : 'n/a (missing data at boundary level)'}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14, height: 1.5), + ), + ), + ], + ); + } +} diff --git a/app/test/domain/wind_math_test.dart b/app/test/domain/wind_math_test.dart new file mode 100644 index 0000000..9402a39 --- /dev/null +++ b/app/test/domain/wind_math_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/domain/wind/wind_math.dart'; + +void main() { + group('interpolateAngle', () { + test('nimmt den kuerzeren Weg ueber die 0/360-Grenze', () { + expect(interpolateAngle(350, 10, 0.5), closeTo(0, 0.001)); + }); + + test('interpoliert linear ohne Grenzuebergang', () { + expect(interpolateAngle(10, 30, 0.5), closeTo(20, 0.001)); + }); + }); + + group('interpolateWindAtHeight', () { + const levels = [ + WindLevel(heightAmslM: 100, speedMs: 4, dirFromDeg: 200), + WindLevel(heightAmslM: 300, speedMs: 8, dirFromDeg: 220), + ]; + + test('klemmt unterhalb des tiefsten Stuetzpunkts', () { + final result = interpolateWindAtHeight(levels, 50); + expect(result!.speedMs, 4); + expect(result.dirFromDeg, 200); + }); + + test('klemmt oberhalb des hoechsten Stuetzpunkts', () { + final result = interpolateWindAtHeight(levels, 500); + expect(result!.speedMs, 8); + expect(result.dirFromDeg, 220); + }); + + test('interpoliert linear zwischen zwei Stuetzpunkten', () { + final result = interpolateWindAtHeight(levels, 200); + expect(result!.speedMs, closeTo(6, 0.001)); + expect(result.dirFromDeg, closeTo(210, 0.001)); + }); + + test('liefert null ohne Stuetzpunkte', () { + expect(interpolateWindAtHeight(const [], 100), isNull); + }); + }); + + group('bearingBetweenDeg', () { + test('nach Norden ergibt ~0 Grad', () { + expect(bearingBetweenDeg(0, 0, 1, 0), closeTo(0, 0.5)); + }); + + test('nach Osten ergibt ~90 Grad', () { + expect(bearingBetweenDeg(0, 0, 0, 1), closeTo(90, 0.5)); + }); + }); + + group('headTailwindComponent', () { + test('Wind von hinten ergibt vollen positiven Rueckenwind', () { + // Kurs 0 (Nord), Wind weht aus Sueden (180) - also von hinten. + expect(headTailwindComponent(0, 10, 180), closeTo(10, 0.001)); + }); + + test('Wind von vorne ergibt vollen negativen Gegenwind', () { + // Kurs 0 (Nord), Wind weht aus Norden (0) - also von vorne. + expect(headTailwindComponent(0, 10, 0), closeTo(-10, 0.001)); + }); + + test('Seitenwind ergibt ~0 Laengskomponente', () { + expect(headTailwindComponent(0, 10, 90), closeTo(0, 0.001)); + }); + }); + + group('computeLegWindComponent / computeLegWind / computeGroundSpeeds', () { + const a = Waypoint( + lat: 0, + lon: 0, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + ); + const bWithWind = Waypoint( + lat: 1, + lon: 0, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + windSpeedMs: 10, + windDirFromDeg: 180, + ); + const bWithoutWind = Waypoint( + lat: 1, + lon: 0, + altitudeM: 60, + speedMs: 15, + catchRadiusM: 60, + ); + + test('null solange am Zielwegpunkt kein Wind geladen ist', () { + expect(computeLegWindComponent(a, bWithoutWind), isNull); + }); + + test('Rueckenwind auf dem Leg erhoeht die Bodengeschwindigkeit', () { + final component = computeLegWindComponent(a, bWithWind); + expect(component, closeTo(10, 0.001)); + + final groundSpeeds = computeGroundSpeeds([a, bWithWind]); + expect(groundSpeeds.single, closeTo(25, 0.001)); + }); + + test('computeLegWind liefert eine Komponente pro Leg', () { + final legWind = computeLegWind([a, bWithWind, bWithoutWind]); + expect(legWind, hasLength(2)); + expect(legWind[0], isNotNull); + expect(legWind[1], isNull); + }); + }); +} diff --git a/app/test/services/wind_service_test.dart b/app/test/services/wind_service_test.dart new file mode 100644 index 0000000..24d3b0b --- /dev/null +++ b/app/test/services/wind_service_test.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; +import 'package:dmc_app/services/wind/wind_service.dart'; + +bool _isPressureRequest(http.Request request) => + (request.url.queryParameters['hourly'] ?? '').contains('hPa'); + +http.Response _json(Object body) => + http.Response(jsonEncode(body), 200, headers: { + 'content-type': 'application/json', + }); + +void main() { + group('WindService.fetchHeaderWind', () { + test('interpoliert auf 120 m AGL ueber dem Referenzort', () async { + final client = MockClient((request) async { + if (_isPressureRequest(request)) { + return http.Response('', 500); + } + return _json({ + 'elevation': 2.0, + 'hourly': { + 'time': ['2024-06-01T12:00'], + 'wind_speed_10m': [3.0], + 'wind_direction_10m': [190.0], + 'wind_speed_80m': [5.0], + 'wind_direction_80m': [200.0], + 'wind_speed_120m': [6.0], + 'wind_direction_120m': [205.0], + 'wind_speed_180m': [8.0], + 'wind_direction_180m': [210.0], + 'wind_gusts_10m': [7.5], + }, + }); + }); + final service = WindService(client: client); + + final result = await service.fetchHeaderWind(52.0, 4.0); + + // Ziel 2 + 120 = 122 m AMSL faellt exakt auf den 120-m-Stuetzpunkt. + expect(result, isNotNull); + expect(result!.speedMs, closeTo(6.0, 0.001)); + expect(result.dirFromDeg, closeTo(205.0, 0.001)); + }); + + test('liefert null, wenn beide Requests fehlschlagen', () async { + final client = MockClient((request) async => http.Response('', 500)); + final service = WindService(client: client); + + final result = await service.fetchHeaderWind(52.0, 4.0); + + expect(result, isNull); + }); + }); + + group('WindService.fetchWindForWaypoints', () { + test('interpoliert jeden Wegpunkt an seinem eigenen Ort/seiner eigenen Hoehe', + () async { + final client = MockClient((request) async { + if (_isPressureRequest(request)) { + return http.Response('', 500); + } + return _json([ + { + 'elevation': 2.0, + 'hourly': { + 'time': ['2024-06-01T12:00'], + 'wind_speed_10m': [3.0], + 'wind_direction_10m': [190.0], + 'wind_speed_80m': [5.0], + 'wind_direction_80m': [200.0], + 'wind_speed_120m': [6.0], + 'wind_direction_120m': [205.0], + 'wind_speed_180m': [8.0], + 'wind_direction_180m': [210.0], + 'wind_gusts_10m': [7.5], + }, + }, + { + 'elevation': 50.0, + 'hourly': { + 'time': ['2024-06-01T12:00'], + 'wind_speed_10m': [1.0], + 'wind_direction_10m': [100.0], + 'wind_speed_80m': [9.0], + 'wind_direction_80m': [250.0], + 'wind_speed_120m': [10.0], + 'wind_direction_120m': [260.0], + 'wind_speed_180m': [11.0], + 'wind_direction_180m': [270.0], + 'wind_gusts_10m': [12.0], + }, + }, + ]); + }); + final service = WindService(client: client); + + final waypoints = [ + const Waypoint( + lat: 52.0, + lon: 4.0, + altitudeM: 10, // 2 + 10 = 12 m AMSL: exakt der 10-m-Stuetzpunkt + speedMs: 15, + catchRadiusM: 60, + ), + const Waypoint( + lat: 52.1, + lon: 4.1, + altitudeM: 80, // 50 + 80 = 130 m AMSL: exakt der 80-m-Stuetzpunkt + speedMs: 15, + catchRadiusM: 60, + ), + ]; + + final result = await service.fetchWindForWaypoints(waypoints); + + expect(result[0].windSpeedMs, closeTo(3.0, 0.001)); + expect(result[0].windDirFromDeg, closeTo(190.0, 0.001)); + expect(result[0].windElevationM, closeTo(2.0, 0.001)); + + expect(result[1].windSpeedMs, closeTo(9.0, 0.001)); + expect(result[1].windDirFromDeg, closeTo(250.0, 0.001)); + expect(result[1].windElevationM, closeTo(50.0, 0.001)); + }); + + test('leere Wegpunktliste loest keinen Request aus', () async { + var called = false; + final client = MockClient((request) async { + called = true; + return http.Response('', 500); + }); + final service = WindService(client: client); + + final result = await service.fetchWindForWaypoints(const []); + + expect(result, isEmpty); + expect(called, isFalse); + }); + }); + + group('WindService.fetchValidation', () { + test('markiert unvollstaendige/unter Boden liegende Druckflaechen als excluded', + () async { + final client = MockClient((request) async { + if (_isPressureRequest(request)) { + return _json({ + 'elevation': 50.0, + 'hourly': { + 'time': ['2024-06-01T12:00'], + 'wind_speed_1000hPa': [4.0], + 'wind_direction_1000hPa': [300.0], + 'geopotential_height_1000hPa': [5.0], // unter Bodenhoehe 50 + 'wind_speed_975hPa': [12.0], + 'wind_direction_975hPa': [300.0], + 'geopotential_height_975hPa': [800.0], + }, + }); + } + return _json({ + 'elevation': 50.0, + 'hourly': { + 'time': ['2024-06-01T12:00'], + 'wind_speed_10m': [1.0], + 'wind_direction_10m': [100.0], + 'wind_speed_80m': [9.0], + 'wind_direction_80m': [250.0], + 'wind_speed_120m': [10.0], + 'wind_direction_120m': [260.0], + 'wind_speed_180m': [11.0], + 'wind_direction_180m': [270.0], + 'wind_gusts_10m': [12.5], + }, + }); + }); + final service = WindService(client: client); + + final result = await service.fetchValidation(52.0, 4.0); + + expect(result.elevationM, closeTo(50.0, 0.001)); + expect(result.fixedRequestFailed, isFalse); + expect(result.pressureRequestFailed, isFalse); + expect(result.gustMs, closeTo(12.5, 0.001)); + + final level1000 = + result.pressureReadings.firstWhere((r) => r.pressureHpa == 1000); + expect(level1000.excluded, isTrue, + reason: 'liegt unter der Bodenhoehe'); + + final level975 = + result.pressureReadings.firstWhere((r) => r.pressureHpa == 975); + expect(level975.excluded, isFalse); + + final level950 = + result.pressureReadings.firstWhere((r) => r.pressureHpa == 950); + expect(level950.excluded, isTrue, + reason: 'keine Daten fuer diese Druckflaeche geliefert'); + + expect(result.interpolatedAt120, isNotNull); + }); + + test('meldet fehlgeschlagenen Request statt zu crashen', () async { + final client = MockClient((request) async { + if (_isPressureRequest(request)) return http.Response('', 500); + return http.Response('', 500); + }); + final service = WindService(client: client); + + final result = await service.fetchValidation(52.0, 4.0); + + expect(result.fixedRequestFailed, isTrue); + expect(result.pressureRequestFailed, isTrue); + expect(result.interpolatedAt120, isNull); + }); + }); +}