Redesigns the waypoint list panel to match the drone-status/missions panels: no title bar, close button floats over the tab row, tabs get more vertical padding. Removes the "Fetch wind" and "Validate" header buttons along with the now-fully-orphaned WindValidatePanel screen and its WindService support code (fetchValidation, WindValidationResult and friends) - nothing else referenced them. Wind is now fetched automatically instead: HeaderWindNotifier gains ensurePerWaypointWind(), called when the waypoint list panel opens and when the header pill's per-waypoint toggle switches on. A lat/lon signature of the waypoint list tracks whether the fetched data is still current, so a ref.listen(currentMissionProvider) in the same notifier refetches only when the list itself changes (position added/ removed/moved) - not on every altitude/speed edit. Waypoints that already carry wind data (e.g. loaded from a saved mission) short- circuit the check instead of re-fetching pointlessly. Also wires per-waypoint wind markers into the fly-mode map, which never had them (plan_screen.dart built windMarkers; fly_screen.dart just never did) - the reported "doesn't work in fly mode" bug. windMarkerWidth() moved from a plan_screen.dart-private method to wind_marker_pill.dart so both screens can share it. Test containers in widget_test.dart now override windServiceProvider with a MockClient so the new auto-fetch trigger can't reach the real network during tests.
207 lines
7.4 KiB
Dart
207 lines
7.4 KiB
Dart
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;
|
|
}
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
}
|