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
+104 -20
View File
@@ -3,16 +3,13 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' hide Path;
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
/// Vollbild-Chart zum Anpassen von Hoehe/Geschwindigkeit per Drag auf den
/// Punkten (HTML-Demonstrator: buildFullChart()/attachChartDrag()). Jeder
/// Punkt entspricht einem Wegpunkt, X-Position proportional zur
/// zurueckgelegten Distanz entlang der Route.
///
/// Terrain-Overlay und Wind-Diamanten aus dem Demonstrator sind nicht
/// portiert, da Terrain- (Doku 3.9) und Wind-System (Doku 3.8) in der
/// Flutter-App noch nicht existieren.
class FullValueChart extends StatefulWidget {
const FullValueChart({
super.key,
@@ -29,6 +26,9 @@ class FullValueChart extends StatefulWidget {
required this.onChanged,
this.legWind,
this.groundSpeeds,
this.terrainPoints,
this.terrainDangerRanges,
this.terrainLoading = false,
});
final List<Waypoint> waypoints;
@@ -51,6 +51,17 @@ class FullValueChart extends StatefulWidget {
/// Laenge/Null-Semantik wie [legWind].
final List<double?>? groundSpeeds;
/// Gelaendeprofil entlang der Route (nur Altitude-Tab, Doku 3.9), relativ
/// zum ersten gueltigen Sample - null, solange noch nicht geladen.
final List<TerrainSamplePoint>? terrainPoints;
/// Streckenabschnitte mit zu geringem Bodenabstand (Doku 3.9), als
/// [start, end]-Distanzpaare.
final List<(double, double)>? terrainDangerRanges;
/// Zeigt einen dezenten Hinweis, solange das Gelaendeprofil noch laedt.
final bool terrainLoading;
static const _distance = Distance(roundResult: false);
static List<double> cumulativeDistances(List<Waypoint> waypoints) {
@@ -80,6 +91,8 @@ class _FullValueChartState extends State<FullValueChart> {
...widget.values,
for (final v in widget.legWind ?? const <double?>[]) ?v,
for (final v in widget.groundSpeeds ?? const <double?>[]) ?v,
for (final p in widget.terrainPoints ?? const <TerrainSamplePoint>[])
?p.elevationRelM,
];
if (allVals.isEmpty) return (lo: widget.min, hi: widget.max);
final dataMin = allVals.reduce(math.min);
@@ -160,21 +173,36 @@ class _FullValueChartState extends State<FullValueChart> {
},
onPanEnd: (_) => _dragIndex = null,
onPanCancel: () => _dragIndex = null,
child: CustomPaint(
size: size,
painter: _ChartPainter(
waypoints: widget.waypoints,
values: widget.values,
cumDist: cumDist,
legBad: widget.legBad,
vertexBad: widget.vertexBad,
activeIndex: widget.activeIndex,
lo: range.lo,
hi: range.hi,
unit: widget.unit,
legWind: widget.legWind,
groundSpeeds: widget.groundSpeeds,
),
child: Stack(
children: [
CustomPaint(
size: size,
painter: _ChartPainter(
waypoints: widget.waypoints,
values: widget.values,
cumDist: cumDist,
legBad: widget.legBad,
vertexBad: widget.vertexBad,
activeIndex: widget.activeIndex,
lo: range.lo,
hi: range.hi,
unit: widget.unit,
legWind: widget.legWind,
groundSpeeds: widget.groundSpeeds,
terrainPoints: widget.terrainPoints,
terrainDangerRanges: widget.terrainDangerRanges,
),
),
if (widget.terrainLoading && widget.terrainPoints == null)
const Positioned(
left: 50,
bottom: 4,
child: Text(
'Loading terrain…',
style: TextStyle(color: Colors.white38, fontSize: 10),
),
),
],
),
);
},
@@ -195,6 +223,8 @@ class _ChartPainter extends CustomPainter {
required this.unit,
this.legWind,
this.groundSpeeds,
this.terrainPoints,
this.terrainDangerRanges,
});
final List<Waypoint> waypoints;
@@ -208,6 +238,8 @@ class _ChartPainter extends CustomPainter {
final String unit;
final List<double?>? legWind;
final List<double?>? groundSpeeds;
final List<TerrainSamplePoint>? terrainPoints;
final List<(double, double)>? terrainDangerRanges;
static const _padX = 50.0;
static const _padY = 20.0;
@@ -219,6 +251,11 @@ class _ChartPainter extends CustomPainter {
return _padX + (cumDist[idx] / total) * (boxW - 2 * _padX);
}
double _xAtDist(double distanceM, double boxW) {
final total = cumDist.isEmpty || cumDist.last == 0 ? 1.0 : cumDist.last;
return _padX + (distanceM / total) * (boxW - 2 * _padX);
}
double _yAt(double val, double boxH) =>
boxH - _padY - ((val - lo) / (hi - lo)) * (boxH - 2 * _padY);
@@ -239,6 +276,10 @@ class _ChartPainter extends CustomPainter {
final boxH = size.height;
final n = waypoints.length;
// Gefahrenzonen zuunterst, damit Gitter/Gelaende/Kurve gut lesbar
// darueber liegen (HTML-Demonstrator: dangerHtml vor grid).
_paintDangerBands(canvas, boxW, boxH);
// Gridlines mit gerundeten Werten.
final step = _niceStep(hi - lo, 5);
final gridPaint = Paint()
@@ -262,6 +303,7 @@ class _ChartPainter extends CustomPainter {
v += step;
}
_paintTerrain(canvas, boxW, boxH);
_paintWindAndGroundSpeed(canvas, boxW, boxH);
// Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate).
@@ -295,6 +337,46 @@ class _ChartPainter extends CustomPainter {
}
}
/// Rote Bereiche mit zu geringem Bodenabstand (nur Altitude-Tab, Doku
/// 3.9/3.10) - analog dangerHtml im HTML-Demonstrator, ganz zuunterst
/// gezeichnet.
void _paintDangerBands(Canvas canvas, double boxW, double boxH) {
final ranges = terrainDangerRanges;
if (ranges == null || ranges.isEmpty) return;
final paint = Paint()..color = const Color(0xFFE2574A).withValues(alpha: 0.22);
for (final (start, end) in ranges) {
final x0 = _xAtDist(start, boxW);
final x1 = _xAtDist(end, boxW);
canvas.drawRect(Rect.fromLTWH(x0, 0, math.max(x1 - x0, 2), boxH), paint);
}
}
/// Braune Gelaende-Flaeche unter dem Hoehenprofil (nur Altitude-Tab, Doku
/// 3.9) - analog terrainHtml im HTML-Demonstrator, direkt nach dem
/// Gitter und vor Wind/Soll-Kurve gezeichnet.
void _paintTerrain(Canvas canvas, double boxW, double boxH) {
final points = terrainPoints;
if (points == null) return;
final valid = points.where((p) => p.elevationRelM != null).toList();
if (valid.length < 2) return;
final path = Path()..moveTo(_xAtDist(valid.first.distanceM, boxW), boxH - _padY);
for (final p in valid) {
path.lineTo(_xAtDist(p.distanceM, boxW), _yAt(p.elevationRelM!, boxH));
}
path.lineTo(_xAtDist(valid.last.distanceM, boxW), boxH - _padY);
path.close();
canvas.drawPath(path, Paint()..color = const Color(0xFF786450).withValues(alpha: 0.45));
canvas.drawPath(
path,
Paint()
..color = const Color(0xFFAA9678).withValues(alpha: 0.8)
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
/// 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-
@@ -426,5 +508,7 @@ class _ChartPainter extends CustomPainter {
oldDelegate.lo != lo ||
oldDelegate.hi != hi ||
oldDelegate.legWind != legWind ||
oldDelegate.groundSpeeds != groundSpeeds;
oldDelegate.groundSpeeds != groundSpeeds ||
oldDelegate.terrainPoints != terrainPoints ||
oldDelegate.terrainDangerRanges != terrainDangerRanges;
}