Add terrain/elevation data fetching (Doku 3.9)

Rundet das Altitude-Tab um ein reales Gelaendeprofil ab, damit sichtbar
wird, ob die geplante Flughoehe ausreichend Bodenabstand haelt:

- domain/mission/terrain_math.dart: reine Sampling-/Geometrie-Funktionen
  (routeKeyForTerrain fuer Caching, distanzbasiertes Sampling alle 30m,
  lineare Sollhoehen-Interpolation, terrainDangerRanges fuer Abschnitte
  mit < 10m Bodenabstand) - unabhaengig testbar ohne Netzwerk/UI.
- services/terrain/terrain_service.dart: laedt AWS-Terrarium-PNG-Kacheln
  (elevation-tiles-prod, zoom 13) und dekodiert sie ueber dart:ui
  (instantiateImageCodec/toByteData), ohne zusaetzliches Bildpaket.
  Elevation = R*256 + G + B/256 - 32768 pro Pixel; Kacheln werden pro
  Route nur einmal geladen (nach Kachel gruppierte Sample-Punkte).
- ui/providers/terrain_provider.dart: cached das geladene Profil ueber
  routeKeyForTerrain - reine Werteaenderungen (Hoehe/Speed) loesen keinen
  erneuten Kachel-Download aus, nur eine tatsaechliche Ortsverschiebung.
- ui/widgets/full_value_chart.dart: _paintDangerBands/_paintTerrain
  zeichnen rote Gefahrenbaender bzw. die braune Gelaende-Flaeche, in der
  gleichen Reihenfolge wie im HTML-Demonstrator (dangerHtml, grid,
  terrainHtml, dann Soll-Kurve obenauf).
- ui/widgets/waypoint_list_panel.dart: triggert ensureFor() beim
  Wechsel auf den Altitude-Tab sowie bei Routenaenderungen waehrend
  dieser aktiv ist (ref.listenManual auf currentMissionProvider).

Getestet: 13 neue Unit-Tests fuer die reine Geometrie/Sampling-Logik,
3 Service-Tests mit einer zur Testzeit synthetisch erzeugten PNG
(dart:ui-Encoding, keine Testasset-Datei noetig). Alle 80 Tests sowie
flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert:
Altitude-Tab zeigt das reale Amsterdam-Gelaendeprofil (nahe Meereshoehe)
korrekt als Flaeche unter der Sollhoehen-Linie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-28 18:38:23 +02:00
co-authored by Claude Sonnet 5
parent 5505bb3741
commit b176ad294e
7 changed files with 692 additions and 20 deletions
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/default_drone_profile.dart';
import '../../domain/mission/drone_profile.dart';
import '../../domain/mission/route_geometry.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart';
import '../providers/active_drone_profile_provider.dart';
import '../providers/current_mission_provider.dart';
import '../providers/terrain_provider.dart';
import '../providers/wind_provider.dart';
import 'full_value_chart.dart';
import 'waypoint_chip.dart';
@@ -74,6 +76,16 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
// Laedt/aktualisiert das Gelaendeprofil (Doku 3.9) nur, waehrend der
// Altitude-Tab tatsaechlich sichtbar ist - aendert sich die Route
// waehrenddessen (Editieren, Loeschen, neuer Wegpunkt), muss neu
// gesampelt werden. ensureFor() selbst filtert per routeKey, ob ein
// erneuter Kachel-Download noetig ist.
ref.listenManual(currentMissionProvider, (previous, next) {
if (_tab == _PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(next);
}
});
}
@override
@@ -137,6 +149,7 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider);
final droneProfile = ref.watch(activeDroneProfileProvider);
final terrainState = ref.watch(terrainProvider);
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: droneProfile.minTurnRadius,
@@ -204,6 +217,11 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
),
_PanelTab.speed => _chartTab(
waypoints,
@@ -234,6 +252,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
setState(() => _tab = tab);
if (tab == _PanelTab.list) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
} else if (tab == _PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
}
},
child: Container(
@@ -272,6 +292,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
required void Function(int index, double value) onChanged,
List<double?>? legWind,
List<double?>? groundSpeeds,
List<TerrainSamplePoint>? terrainPoints,
List<(double, double)>? terrainDangerRanges,
bool terrainLoading = false,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
@@ -289,6 +312,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
onChanged: onChanged,
legWind: legWind,
groundSpeeds: groundSpeeds,
terrainPoints: terrainPoints,
terrainDangerRanges: terrainDangerRanges,
terrainLoading: terrainLoading,
),
);
}