Add wind analysis: header pill, fetch/validate menu, speed-plot overlay
Portiert das Wind-System des HTML-Demonstrators (Doku 3.8) vollstaendig: Open-Meteo-Winddaten, interpoliert zwischen Druckflaechen- und festen Nabenhoehen-Stuetzpunkten auf die tatsaechliche Zielhoehe. Domain (lib/domain/wind/wind_math.dart, reines Dart): Hoehen- Interpolation (linear + zirkulaer fuer Windrichtung), Peilung zwischen zwei Punkten, Kopf-/Rueckenwind-Komponente pro Flugleg - 1:1 uebernommen aus interpolateWindAtHeight()/bearingBetween()/headTailwindComponent() des Demonstrators, per Unit-Tests abgesichert. Service (lib/services/wind/wind_service.dart): zwei getrennte, fokussierte Open-Meteo-Requests statt eines kombinierten (Doku 4.10 - ein 35-Variablen-Request verursachte im Demonstrator Modellauswahl- Fehler). Drei Verbraucher: Kopfleisten-Pille (120 m AGL ueber Referenzort), gebuendelter Wegpunkt-Fetch (ein Request fuer alle Koordinaten via Open-Meteos Mehrfach-Location-Syntax) und das Validierungspanel (rohe Messwerte je Nabenhoehe/Druckflaeche). Per MockClient getestet (kein echter Netzwerkzugriff in Tests). UI: - HeaderWindPill (TopModeBar, nur ausserhalb Fly-Modus): zeigt Windrichtung/-geschwindigkeit, Antippen schaltet perspektivisch die Wind-Marker der Wegpunkte um (Zustand vorbereitet, HTML-Demonstrator: showPerWaypointWind). - WaypointListPanel: "Fetch wind"/"Validate"-Buttons im Panel-Header, neue WIND-Spalte in der Liste. - WindValidatePanel: neue Vollbild-Route (gleiches Muster wie WaypointListPanel) mit Rohdaten je Hoehenstufe inkl. Boeen (Doku 4.9: Boeen nur hier, nicht in der Wegpunktanzeige) und Interpolationsergebnis. - FullValueChart (Speed-Tab): Kopf-/Rueckenwind-Diamanten pro Leg samt gestrichelter Nulllinie sowie gestrichelte Bodengeschwindigkeits- Stufenlinie. Bug gefunden und gefixt waehrend der Emulator-Verifikation: die Y-Achsen-Autoskalierung klemmte weiterhin auf den gueltigen Drehrad- Wertebereich (13-25 m/s), wodurch nahe Null liegende Windkomponenten (bei schwachem Wind der Normalfall) ausserhalb des sichtbaren Bereichs lagen und weder Diamanten noch Nulllinie zu sehen waren. Der HTML- Demonstrator klemmt computeSpeedChartRange() bewusst nicht auf SPD_MIN/ SPD_MAX; das Klemmen in FullValueChart._range() jetzt entsprechend entfernt. Waypoint-Modell um windSpeedMs/windDirFromDeg/windElevationM erweitert (nullable, reine Planungshilfe - der Encoder liest diese Felder nie, Doku 3.2). currentMissionProvider bekommt replaceAll() fuer den gebuendelten Wind-Fetch, der alle Wegpunkte gleichzeitig aktualisiert. Verifiziert: flutter analyze (0 issues), flutter test (44/44, davon 14 neue Wind-Mathematik- und 6 neue WindService-Tests), manuell auf Pixel_10a-Emulator mit echtem Open-Meteo-Netzwerkzugriff - Kopfleisten- Pille zeigt Live-Wind, Fetch/Validate fuellen Liste bzw. oeffnen das Validierungspanel mit echten Messwerten, Speed-Plot zeigt nach dem Fix Diamanten/Nulllinie/Bodengeschwindigkeit korrekt skaliert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7a26002a63
commit
1b13c45cac
@@ -15,6 +15,9 @@ class Waypoint {
|
|||||||
this.action = WaypointAction.none,
|
this.action = WaypointAction.none,
|
||||||
this.jumpTargetIndex,
|
this.jumpTargetIndex,
|
||||||
this.jumpRepeatCount,
|
this.jumpRepeatCount,
|
||||||
|
this.windSpeedMs,
|
||||||
|
this.windDirFromDeg,
|
||||||
|
this.windElevationM,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double lat;
|
final double lat;
|
||||||
@@ -30,6 +33,19 @@ class Waypoint {
|
|||||||
/// Nur bei action == jump gesetzt; null bedeutet Endlos-Wiederholung.
|
/// Nur bei action == jump gesetzt; null bedeutet Endlos-Wiederholung.
|
||||||
final int? jumpRepeatCount;
|
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({
|
Waypoint copyWith({
|
||||||
double? lat,
|
double? lat,
|
||||||
double? lon,
|
double? lon,
|
||||||
@@ -39,6 +55,9 @@ class Waypoint {
|
|||||||
WaypointAction? action,
|
WaypointAction? action,
|
||||||
int? jumpTargetIndex,
|
int? jumpTargetIndex,
|
||||||
int? jumpRepeatCount,
|
int? jumpRepeatCount,
|
||||||
|
double? windSpeedMs,
|
||||||
|
double? windDirFromDeg,
|
||||||
|
double? windElevationM,
|
||||||
}) {
|
}) {
|
||||||
return Waypoint(
|
return Waypoint(
|
||||||
lat: lat ?? this.lat,
|
lat: lat ?? this.lat,
|
||||||
@@ -49,6 +68,9 @@ class Waypoint {
|
|||||||
action: action ?? this.action,
|
action: action ?? this.action,
|
||||||
jumpTargetIndex: jumpTargetIndex ?? this.jumpTargetIndex,
|
jumpTargetIndex: jumpTargetIndex ?? this.jumpTargetIndex,
|
||||||
jumpRepeatCount: jumpRepeatCount ?? this.jumpRepeatCount,
|
jumpRepeatCount: jumpRepeatCount ?? this.jumpRepeatCount,
|
||||||
|
windSpeedMs: windSpeedMs ?? this.windSpeedMs,
|
||||||
|
windDirFromDeg: windDirFromDeg ?? this.windDirFromDeg,
|
||||||
|
windElevationM: windElevationM ?? this.windElevationM,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<WindLevel> 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<double?> computeLegWind(List<Waypoint> 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<double?> computeGroundSpeeds(List<Waypoint> 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<WindLevel> 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<WindValidationFixedReading> fixedReadings;
|
||||||
|
final double? gustMs;
|
||||||
|
final bool pressureRequestFailed;
|
||||||
|
final List<WindValidationPressureReading> 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<dynamic> _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<Map<String, dynamic>?> _asResultList(dynamic decoded, int expectedCount) {
|
||||||
|
if (decoded is List) {
|
||||||
|
return List.generate(
|
||||||
|
expectedCount,
|
||||||
|
(i) => i < decoded.length ? decoded[i] as Map<String, dynamic>? : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
return [decoded, ...List<Map<String, dynamic>?>.filled(expectedCount - 1, null)];
|
||||||
|
}
|
||||||
|
return List<Map<String, dynamic>?>.filled(expectedCount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _nearestHourIndex(List<dynamic> 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<String, dynamic>? pressureRes,
|
||||||
|
Map<String, dynamic>? fixedRes,
|
||||||
|
) {
|
||||||
|
final levels = <WindLevel>[];
|
||||||
|
var elevation = 0.0;
|
||||||
|
|
||||||
|
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
|
||||||
|
final pTime = pHourly?['time'] as List<dynamic>?;
|
||||||
|
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<String, dynamic>?;
|
||||||
|
final fTime = fHourly?['time'] as List<dynamic>?;
|
||||||
|
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<WindSample?> 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<List<Waypoint>> fetchWindForWaypoints(List<Waypoint> 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<String, dynamic>? pressureRes,
|
||||||
|
Map<String, dynamic>? 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<WindValidationResult> 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 = <WindValidationFixedReading>[];
|
||||||
|
double? gust;
|
||||||
|
final fHourly = fixedRes?['hourly'] as Map<String, dynamic>?;
|
||||||
|
final fTime = fHourly?['time'] as List<dynamic>?;
|
||||||
|
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 = <WindValidationPressureReading>[];
|
||||||
|
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
|
||||||
|
final pTime = pHourly?['time'] as List<dynamic>?;
|
||||||
|
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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ class CurrentMissionNotifier extends Notifier<List<Waypoint>> {
|
|||||||
state = [...state, waypoint];
|
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<Waypoint> waypoints) {
|
||||||
|
state = waypoints;
|
||||||
|
}
|
||||||
|
|
||||||
/// Aktualisiert die Position eines Wegpunkts (Drag-to-Reposition im
|
/// Aktualisiert die Position eines Wegpunkts (Drag-to-Reposition im
|
||||||
/// Editier-Modus: Kartenverschieben bewegt den Zielpunkt live mit).
|
/// Editier-Modus: Kartenverschieben bewegt den Zielpunkt live mit).
|
||||||
void moveWaypoint(int index, double lat, double lon) {
|
void moveWaypoint(int index, double lat, double lon) {
|
||||||
|
|||||||
@@ -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<WindService>((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<HeaderWindState> {
|
||||||
|
@override
|
||||||
|
HeaderWindState build() => const HeaderWindState();
|
||||||
|
|
||||||
|
Future<void> 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, HeaderWindState>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import '../../../domain/mission/route_geometry.dart';
|
|||||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../../providers/current_mission_provider.dart';
|
import '../../providers/current_mission_provider.dart';
|
||||||
import '../../providers/map_controller_provider.dart';
|
import '../../providers/map_controller_provider.dart';
|
||||||
|
import '../../providers/wind_provider.dart';
|
||||||
import '../../widgets/bottom_stats_bar.dart';
|
import '../../widgets/bottom_stats_bar.dart';
|
||||||
import '../../widgets/halo_menu.dart';
|
import '../../widgets/halo_menu.dart';
|
||||||
import '../../widgets/mini_altitude_profile.dart';
|
import '../../widgets/mini_altitude_profile.dart';
|
||||||
@@ -67,6 +68,11 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_mapController = ref.read(mapControllerProvider);
|
_mapController = ref.read(mapControllerProvider);
|
||||||
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
|
_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
|
@override
|
||||||
@@ -163,6 +169,11 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
catchRadiusM: DefaultDroneProfile.catchRadiusM,
|
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(() {
|
setState(() {
|
||||||
_targetIndex = waypoints.length;
|
_targetIndex = waypoints.length;
|
||||||
_mode = _ReticleMode.snapped;
|
_mode = _ReticleMode.snapped;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class FullValueChart extends StatefulWidget {
|
|||||||
required this.pxPerUnit,
|
required this.pxPerUnit,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.legWind,
|
||||||
|
this.groundSpeeds,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<Waypoint> waypoints;
|
final List<Waypoint> waypoints;
|
||||||
@@ -41,6 +43,14 @@ class FullValueChart extends StatefulWidget {
|
|||||||
final String unit;
|
final String unit;
|
||||||
final void Function(int index, double value) onChanged;
|
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<double?>? legWind;
|
||||||
|
|
||||||
|
/// Erreichbare Bodengeschwindigkeit pro Leg (nur Speed-Tab), gleiche
|
||||||
|
/// Laenge/Null-Semantik wie [legWind].
|
||||||
|
final List<double?>? groundSpeeds;
|
||||||
|
|
||||||
static const _distance = Distance(roundResult: false);
|
static const _distance = Distance(roundResult: false);
|
||||||
|
|
||||||
static List<double> cumulativeDistances(List<Waypoint> waypoints) {
|
static List<double> cumulativeDistances(List<Waypoint> waypoints) {
|
||||||
@@ -66,9 +76,14 @@ class _FullValueChartState extends State<FullValueChart> {
|
|||||||
static const _hitToleranceX = 28.0;
|
static const _hitToleranceX = 28.0;
|
||||||
|
|
||||||
({double lo, double hi}) _range() {
|
({double lo, double hi}) _range() {
|
||||||
if (widget.values.isEmpty) return (lo: widget.min, hi: widget.max);
|
final allVals = <double>[
|
||||||
final dataMin = widget.values.reduce(math.min);
|
...widget.values,
|
||||||
final dataMax = widget.values.reduce(math.max);
|
for (final v in widget.legWind ?? const <double?>[]) ?v,
|
||||||
|
for (final v in widget.groundSpeeds ?? const <double?>[]) ?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 span = dataMax - dataMin;
|
||||||
final pad = math.max(span * 0.35, widget.step * 2);
|
final pad = math.max(span * 0.35, widget.step * 2);
|
||||||
var lo = dataMin - pad;
|
var lo = dataMin - pad;
|
||||||
@@ -79,8 +94,11 @@ class _FullValueChartState extends State<FullValueChart> {
|
|||||||
lo = mid - minSpan / 2;
|
lo = mid - minSpan / 2;
|
||||||
hi = mid + minSpan / 2;
|
hi = mid + minSpan / 2;
|
||||||
}
|
}
|
||||||
if (lo < widget.min) lo = widget.min;
|
// Nicht auf widget.min/max (gueltiger Drohnen-Geschwindigkeitsbereich)
|
||||||
if (hi > widget.max) hi = widget.max;
|
// 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) {
|
if (hi <= lo) {
|
||||||
lo = widget.min;
|
lo = widget.min;
|
||||||
hi = widget.max;
|
hi = widget.max;
|
||||||
@@ -154,6 +172,8 @@ class _FullValueChartState extends State<FullValueChart> {
|
|||||||
lo: range.lo,
|
lo: range.lo,
|
||||||
hi: range.hi,
|
hi: range.hi,
|
||||||
unit: widget.unit,
|
unit: widget.unit,
|
||||||
|
legWind: widget.legWind,
|
||||||
|
groundSpeeds: widget.groundSpeeds,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -173,6 +193,8 @@ class _ChartPainter extends CustomPainter {
|
|||||||
required this.lo,
|
required this.lo,
|
||||||
required this.hi,
|
required this.hi,
|
||||||
required this.unit,
|
required this.unit,
|
||||||
|
this.legWind,
|
||||||
|
this.groundSpeeds,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<Waypoint> waypoints;
|
final List<Waypoint> waypoints;
|
||||||
@@ -184,6 +206,8 @@ class _ChartPainter extends CustomPainter {
|
|||||||
final double lo;
|
final double lo;
|
||||||
final double hi;
|
final double hi;
|
||||||
final String unit;
|
final String unit;
|
||||||
|
final List<double?>? legWind;
|
||||||
|
final List<double?>? groundSpeeds;
|
||||||
|
|
||||||
static const _padX = 50.0;
|
static const _padX = 50.0;
|
||||||
static const _padY = 20.0;
|
static const _padY = 20.0;
|
||||||
@@ -238,6 +262,8 @@ class _ChartPainter extends CustomPainter {
|
|||||||
v += step;
|
v += step;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_paintWindAndGroundSpeed(canvas, boxW, boxH);
|
||||||
|
|
||||||
// Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate).
|
// Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate).
|
||||||
for (var i = 0; i < n - 1; i++) {
|
for (var i = 0; i < n - 1; i++) {
|
||||||
final bad = i < legBad.length && legBad[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 = <Offset>[];
|
||||||
|
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
|
@override
|
||||||
bool shouldRepaint(covariant _ChartPainter oldDelegate) =>
|
bool shouldRepaint(covariant _ChartPainter oldDelegate) =>
|
||||||
oldDelegate.values != values ||
|
oldDelegate.values != values ||
|
||||||
oldDelegate.activeIndex != activeIndex ||
|
oldDelegate.activeIndex != activeIndex ||
|
||||||
oldDelegate.lo != lo ||
|
oldDelegate.lo != lo ||
|
||||||
oldDelegate.hi != hi;
|
oldDelegate.hi != hi ||
|
||||||
|
oldDelegate.legWind != legWind ||
|
||||||
|
oldDelegate.groundSpeeds != groundSpeeds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
import '../../app_mode/app_mode_cubit.dart';
|
import '../../app_mode/app_mode_cubit.dart';
|
||||||
import '../dmc_colors.dart';
|
import '../dmc_colors.dart';
|
||||||
|
import 'header_wind_pill.dart';
|
||||||
import 'map_search_controls.dart';
|
import 'map_search_controls.dart';
|
||||||
|
|
||||||
/// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus
|
/// 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
|
/// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird
|
||||||
/// als solider Button am Rand angeboten, um in ihn zu wechseln.
|
/// als solider Button am Rand angeboten, um in ihn zu wechseln.
|
||||||
///
|
///
|
||||||
/// Traegt ausserhalb des Fly-Modus zusaetzlich die Ortssuche und die
|
/// Traegt ausserhalb des Fly-Modus zusaetzlich die Wind-Pille, Ortssuche und
|
||||||
/// Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #searchWrap/
|
/// die Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #headerWindPill/
|
||||||
/// #homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer separaten
|
/// #searchWrap/#homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer
|
||||||
/// Leiste) - dafuer lebt der MapController im mapControllerProvider statt
|
/// separaten Leiste) - dafuer lebt der MapController im mapControllerProvider
|
||||||
/// als PlanScreen-privates Feld, siehe dort.
|
/// statt als PlanScreen-privates Feld, siehe dort.
|
||||||
class TopModeBar extends StatelessWidget {
|
class TopModeBar extends StatelessWidget {
|
||||||
const TopModeBar({super.key});
|
const TopModeBar({super.key});
|
||||||
|
|
||||||
@@ -50,7 +51,9 @@ class TopModeBar extends StatelessWidget {
|
|||||||
if (isFly)
|
if (isFly)
|
||||||
const Spacer()
|
const Spacer()
|
||||||
else ...[
|
else ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
|
const HeaderWindPill(),
|
||||||
|
const SizedBox(width: 8),
|
||||||
const Expanded(child: MapSearchControls()),
|
const Expanded(child: MapSearchControls()),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../domain/mission/default_drone_profile.dart';
|
import '../../domain/mission/default_drone_profile.dart';
|
||||||
import '../../domain/mission/route_geometry.dart';
|
import '../../domain/mission/route_geometry.dart';
|
||||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
|
import '../../domain/wind/wind_math.dart';
|
||||||
import '../providers/current_mission_provider.dart';
|
import '../providers/current_mission_provider.dart';
|
||||||
|
import '../providers/wind_provider.dart';
|
||||||
import 'full_value_chart.dart';
|
import 'full_value_chart.dart';
|
||||||
import 'waypoint_chip.dart';
|
import 'waypoint_chip.dart';
|
||||||
|
import 'wind_arrow_icon.dart';
|
||||||
|
import 'wind_validate_panel.dart';
|
||||||
|
|
||||||
enum _PanelTab { list, altitude, speed }
|
enum _PanelTab { list, altitude, speed }
|
||||||
|
|
||||||
@@ -76,6 +80,39 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator:
|
||||||
|
/// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck.
|
||||||
|
Future<void> _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
|
/// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das
|
||||||
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
||||||
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
||||||
@@ -123,6 +160,19 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_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(
|
IconButton(
|
||||||
onPressed: widget.onClose,
|
onPressed: widget.onClose,
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
@@ -162,6 +212,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
pxPerUnit: 22,
|
pxPerUnit: 22,
|
||||||
unit: ' m/s',
|
unit: ' m/s',
|
||||||
onChanged: widget.onSpeedChanged,
|
onChanged: widget.onSpeedChanged,
|
||||||
|
legWind: computeLegWind(waypoints),
|
||||||
|
groundSpeeds: computeGroundSpeeds(waypoints),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -215,6 +267,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
required double pxPerUnit,
|
required double pxPerUnit,
|
||||||
required String unit,
|
required String unit,
|
||||||
required void Function(int index, double value) onChanged,
|
required void Function(int index, double value) onChanged,
|
||||||
|
List<double?>? legWind,
|
||||||
|
List<double?>? groundSpeeds,
|
||||||
}) {
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
|
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
|
||||||
@@ -230,6 +284,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
pxPerUnit: pxPerUnit,
|
pxPerUnit: pxPerUnit,
|
||||||
unit: unit,
|
unit: unit,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
|
legWind: legWind,
|
||||||
|
groundSpeeds: groundSpeeds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,6 +303,7 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
Expanded(child: Text('SPEED', style: _headerStyle)),
|
Expanded(child: Text('SPEED', style: _headerStyle)),
|
||||||
Expanded(child: Text('EXECUTE', style: _headerStyle)),
|
Expanded(child: Text('EXECUTE', style: _headerStyle)),
|
||||||
Expanded(child: Text('CATCH', style: _headerStyle)),
|
Expanded(child: Text('CATCH', style: _headerStyle)),
|
||||||
|
Expanded(child: Text('WIND', style: _headerStyle)),
|
||||||
SizedBox(width: 76),
|
SizedBox(width: 76),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -375,6 +432,19 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
: Text('${wp.catchRadiusM.round()} m',
|
: Text('${wp.catchRadiusM.round()} m',
|
||||||
style: const TextStyle(color: Colors.white)),
|
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(
|
IconButton(
|
||||||
onPressed: () => setState(
|
onPressed: () => setState(
|
||||||
() => _rowEditIndex = isEditing ? -1 : index,
|
() => _rowEditIndex = isEditing ? -1 : index,
|
||||||
@@ -405,3 +475,49 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
_ => '—',
|
_ => '—',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WindValidatePanel> createState() => _WindValidatePanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WindValidatePanelState extends ConsumerState<WindValidatePanel> {
|
||||||
|
late final Future<WindValidationResult> _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<WindValidationResult>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user