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
@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../services/terrain/terrain_service.dart';
final terrainServiceProvider = Provider<TerrainService>((ref) => TerrainService());
/// Zustand des Gelaendeprofils fuer die aktuelle Route (Doku 3.9).
class TerrainState {
const TerrainState({this.profile, this.loading = false});
final TerrainProfile? profile;
final bool loading;
TerrainState copyWith({TerrainProfile? profile, bool? loading}) {
return TerrainState(
profile: profile ?? this.profile,
loading: loading ?? this.loading,
);
}
}
/// Laedt das Gelaendeprofil nur, wenn sich die Route (routeKeyForTerrain)
/// seit dem letzten Laden geaendert hat - analog dem
/// `terrainSamples.routeKey === key`-Cache im HTML-Demonstrator. Ein
/// reiner Werte-/Aktionswechsel (Hoehe, Geschwindigkeit) an bestehenden
/// Wegpunkten loest daher keinen erneuten Kachel-Download aus.
class TerrainNotifier extends Notifier<TerrainState> {
@override
TerrainState build() => const TerrainState();
Future<void> ensureFor(List<Waypoint> waypoints) async {
if (waypoints.length < 2) {
if (state.profile != null) state = const TerrainState();
return;
}
final routeKey = routeKeyForTerrain(waypoints);
if (state.profile?.routeKey == routeKey) return;
if (state.loading) return;
state = state.copyWith(loading: true);
final profile = await ref.read(terrainServiceProvider).fetchProfile(waypoints);
// Waehrenddessen koennte sich die Route erneut geaendert haben - nur
// uebernehmen, wenn das Ergebnis noch zur aktuell angefragten Route passt.
if (profile != null && profile.routeKey != routeKeyForTerrain(waypoints)) {
state = state.copyWith(loading: false);
return;
}
state = TerrainState(profile: profile ?? state.profile, loading: false);
}
}
final terrainProvider = NotifierProvider<TerrainNotifier, TerrainState>(TerrainNotifier.new);