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,78 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
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/terrain/terrain_service.dart';
/// Baut eine 256x256-PNG, deren Pixel alle dieselbe Terrarium-kodierte
/// Hoehe tragen - reicht aus, um die komplette Pipeline (HTTP-Fetch,
/// PNG-Decodierung, Terrarium-Formel, Relativierung) zu pruefen, ohne dass
/// die exakte Pixelposition innerhalb der Kachel eine Rolle spielt.
Future<Uint8List> _uniformElevationPng(int r, int g, int b) async {
const size = 256;
final pixels = Uint8List(size * size * 4);
for (var i = 0; i < size * size; i++) {
pixels[i * 4] = r;
pixels[i * 4 + 1] = g;
pixels[i * 4 + 2] = b;
pixels[i * 4 + 3] = 255;
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
pixels,
size,
size,
ui.PixelFormat.rgba8888,
completer.complete,
);
final image = await completer.future;
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const waypoints = [
Waypoint(lat: 48.1351, lon: 11.5820, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 48.1360, lon: 11.5830, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
test('fetchProfile decodiert die Terrarium-PNG in relative Hoehen', () async {
// R=128, G=100, B=0 -> (128*256 + 100 + 0/256) - 32768 = 100.
final pngBytes = await _uniformElevationPng(128, 100, 0);
final client = MockClient((request) async => http.Response.bytes(pngBytes, 200));
final service = TerrainService(client: client);
final profile = await service.fetchProfile(waypoints);
expect(profile, isNotNull);
expect(profile!.points, isNotEmpty);
// Gleichfoermiges Gelaende: jedes Sample hat dieselbe absolute Hoehe
// wie das erste gueltige -> relative Hoehe ueberall 0.
for (final p in profile.points) {
expect(p.elevationRelM, isNotNull);
expect(p.elevationRelM, closeTo(0, 0.01));
}
expect(profile.points.first.distanceM, 0);
});
test('fetchProfile liefert null, wenn keine Kachel geladen werden kann', () async {
final client = MockClient((request) async => http.Response('', 500));
final service = TerrainService(client: client);
expect(await service.fetchProfile(waypoints), isNull);
});
test('fetchProfile liefert null fuer eine einzelne Wegpunkt-Route', () async {
final client = MockClient((request) async => http.Response('', 500));
final service = TerrainService(client: client);
expect(await service.fetchProfile([waypoints.first]), isNull);
});
}