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
+159
View File
@@ -0,0 +1,159 @@
import 'package:latlong2/latlong.dart';
import '../waypoint/flat_waypoint_list.dart';
/// Distanzbasierte Sampling-Strategie (Doku 3.9): alle 30 m, mind. 10 /
/// max. 2000 Samples - unabhaengig von der Anzahl Wegpunkte, damit auch
/// lange Geradenstuecke zwischen zwei Wegpunkten ein glattes Profil
/// bekommen.
const terrainSampleSpacingM = 30.0;
const terrainMaxSamples = 2000;
/// Mindest-Bodenabstand, unterhalb dessen ein Streckenabschnitt als nicht
/// sicher markiert wird (Doku 3.9/3.10).
const minTerrainClearanceM = 10.0;
const _distance = Distance(roundResult: false);
/// Eindeutiger Schluessel fuer die aktuelle Route (Rundung auf 5
/// Nachkommastellen wie bei der Wind-Interpolation) - aendert sich, sobald
/// sich ein Wegpunkt verschiebt, nicht aber bei reinen Hoehen-/
/// Geschwindigkeitsaenderungen. Damit laesst sich ein bereits geladenes
/// Terrainprofil cachen, analog routeKeyForTerrain() im HTML-Demonstrator.
String routeKeyForTerrain(List<Waypoint> waypoints) {
return waypoints
.map((w) => '${w.lat.toStringAsFixed(5)},${w.lon.toStringAsFixed(5)}')
.join('|');
}
/// Ein Streckenpunkt in [distanceM] Entfernung vom ersten Wegpunkt.
class RouteSamplePoint {
const RouteSamplePoint({required this.distanceM, required this.point});
final double distanceM;
final LatLng point;
}
List<double> _cumulativeDistances(List<Waypoint> waypoints) {
final cum = <double>[0];
for (var i = 1; i < waypoints.length; i++) {
cum.add(cum[i - 1] +
_distance(
LatLng(waypoints[i - 1].lat, waypoints[i - 1].lon),
LatLng(waypoints[i].lat, waypoints[i].lon),
));
}
return cum;
}
/// Wie viele Samples fuer die aktuelle Routenlaenge angemessen sind
/// (Doku 3.9), analog `Math.min(Math.max(Math.ceil(totalRouteDist /
/// TERRAIN_SAMPLE_SPACING), 10), TERRAIN_MAX_SAMPLES)`.
int sampleCountFor(List<Waypoint> waypoints) {
if (waypoints.length < 2) return 0;
final cum = _cumulativeDistances(waypoints);
final total = cum.last;
final raw = (total / terrainSampleSpacingM).ceil();
return raw.clamp(10, terrainMaxSamples);
}
/// Sampelt [numSamples] + 1 Punkte gleichmaessig nach zurueckgelegter
/// Strecke (nicht nach Wegpunktindex) - analog sampleRouteLatLng() im
/// HTML-Demonstrator.
List<RouteSamplePoint> sampleRouteByDistance(
List<Waypoint> waypoints,
int numSamples,
) {
final n = waypoints.length;
if (n < 2 || numSamples <= 0) return [];
final cum = _cumulativeDistances(waypoints);
final total = cum.last == 0 ? 1.0 : cum.last;
final points = <RouteSamplePoint>[];
for (var s = 0; s <= numSamples; s++) {
final target = (s / numSamples) * total;
var legIdx = 0;
while (legIdx < n - 2 && cum[legIdx + 1] < target) {
legIdx++;
}
final legStart = cum[legIdx];
final legEnd = cum[legIdx + 1];
final frac = legEnd > legStart ? (target - legStart) / (legEnd - legStart) : 0.0;
final a = waypoints[legIdx];
final b = waypoints[legIdx + 1];
points.add(RouteSamplePoint(
distanceM: target,
point: LatLng(
a.lat + (b.lat - a.lat) * frac,
a.lon + (b.lon - a.lon) * frac,
),
));
}
return points;
}
/// Ein Gelaende-Sample in [distanceM] Streckenentfernung; [elevationRelM]
/// ist relativ zum ersten gueltigen Sample der Route (nicht absolut ueber
/// dem Meeresspiegel) - null, wo die Kachel nicht geladen werden konnte.
class TerrainSamplePoint {
const TerrainSamplePoint({required this.distanceM, this.elevationRelM});
final double distanceM;
final double? elevationRelM;
}
/// Ein geladenes Gelaendeprofil, an [routeKey] gebunden - passt der
/// aktuelle routeKeyForTerrain()-Wert nicht mehr, muss neu geladen werden.
class TerrainProfile {
const TerrainProfile({required this.routeKey, required this.points});
final String routeKey;
final List<TerrainSamplePoint> points;
}
/// Lineare Interpolation der geplanten (Soll-)Hoehe an einer beliebigen
/// Streckenposition - analog plannedAltAt() im HTML-Demonstrator.
double plannedAltitudeAt(List<Waypoint> waypoints, double distanceM) {
final n = waypoints.length;
if (n == 1) return waypoints.first.altitudeM;
final cum = _cumulativeDistances(waypoints);
if (distanceM <= cum.first) return waypoints.first.altitudeM;
if (distanceM >= cum.last) return waypoints.last.altitudeM;
for (var k = 0; k < n - 1; k++) {
if (distanceM >= cum[k] && distanceM <= cum[k + 1]) {
final span = cum[k + 1] - cum[k];
final frac = span > 0 ? (distanceM - cum[k]) / span : 0.0;
return waypoints[k].altitudeM +
(waypoints[k + 1].altitudeM - waypoints[k].altitudeM) * frac;
}
}
return waypoints.last.altitudeM;
}
/// Streckenabschnitte, an denen der Bodenabstand [minTerrainClearanceM]
/// unterschreitet (als [start, end]-Distanzpaare) - analog
/// checkTerrainClearance() im HTML-Demonstrator.
List<(double, double)> terrainDangerRanges(
List<Waypoint> waypoints,
TerrainProfile profile,
) {
if (waypoints.length < 2) return [];
final ranges = <(double, double)>[];
double? rangeStart;
for (final p in profile.points) {
final elevRel = p.elevationRelM;
if (elevRel == null) continue;
final clearance = plannedAltitudeAt(waypoints, p.distanceM) - elevRel;
final bad = clearance < minTerrainClearanceM;
if (bad) {
rangeStart ??= p.distanceM;
} else if (rangeStart != null) {
ranges.add((rangeStart, p.distanceM));
rangeStart = null;
}
}
if (rangeStart != null) {
ranges.add((rangeStart, profile.points.last.distanceM));
}
return ranges;
}
@@ -0,0 +1,120 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/http.dart' as http;
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
/// Hoehendaten ueber AWS-Terrarium-Kacheln (Doku 3.9): PNG-kodierte
/// Rasterkacheln im selben z/x/y-Schema wie die OSM-Basiskarte, jeder
/// Pixel kodiert eine absolute Hoehe in den RGB-Kanaelen (Terrarium-Format:
/// `elevation = R*256 + G + B/256 - 32768`).
class TerrainService {
TerrainService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const _tileUrlTemplate =
'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png';
static const _zoom = 13;
static const _tileSize = 256;
/// Kachelkoordinate plus Pixelposition innerhalb der Kachel fuer einen
/// Punkt - analog lngLatToTilePixel() im HTML-Demonstrator.
({int tx, int ty, int px, int py}) _tilePixelFor(double lat, double lon) {
final n = math.pow(2, _zoom).toDouble();
final x = (lon + 180) / 360 * n;
final latRad = lat * math.pi / 180;
final y =
(1 - math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) /
2 *
n;
final tx = x.floor();
final ty = y.floor();
final px = ((x - tx) * _tileSize).floor().clamp(0, _tileSize - 1);
final py = ((y - ty) * _tileSize).floor().clamp(0, _tileSize - 1);
return (tx: tx, ty: ty, px: px, py: py);
}
Future<Uint8List?> _fetchTileRgba(int tx, int ty) async {
final url = _tileUrlTemplate
.replaceAll('{z}', '$_zoom')
.replaceAll('{x}', '$tx')
.replaceAll('{y}', '$ty');
try {
final response = await _client.get(Uri.parse(url));
if (response.statusCode != 200) return null;
final codec = await ui.instantiateImageCodec(response.bodyBytes);
final frame = await codec.getNextFrame();
final byteData =
await frame.image.toByteData(format: ui.ImageByteFormat.rawRgba);
return byteData?.buffer.asUint8List();
} catch (_) {
// CORS-Aequivalent/Kachel fehlt/Decodierfehler: Punkt bleibt null,
// kein Absturz (analog dem try/catch um ctx.getImageData() im
// HTML-Demonstrator).
return null;
}
}
double? _elevationAt(Uint8List rgba, int px, int py) {
final offset = (py * _tileSize + px) * 4;
if (offset + 2 >= rgba.length) return null;
final r = rgba[offset];
final g = rgba[offset + 1];
final b = rgba[offset + 2];
return (r * 256 + g + b / 256) - 32768;
}
/// Laedt das Gelaendeprofil fuer die aktuelle Route (Doku 3.9): sampelt
/// distanzbasiert, gruppiert die Sample-Punkte nach Kachel (jede Kachel
/// wird nur einmal geladen) und macht die Hoehe relativ zum ersten
/// gueltigen Sample. Null, wenn keine einzige Kachel geladen werden
/// konnte oder die Route weniger als zwei Wegpunkte hat.
Future<TerrainProfile?> fetchProfile(List<Waypoint> waypoints) async {
if (waypoints.length < 2) return null;
final numSamples = sampleCountFor(waypoints);
final samples = sampleRouteByDistance(waypoints, numSamples);
if (samples.isEmpty) return null;
final tileGroups = <String, List<int>>{};
final tileCoords = <String, (int, int)>{};
final pixelForIndex = <int, (int, int)>{};
for (var i = 0; i < samples.length; i++) {
final t = _tilePixelFor(samples[i].point.latitude, samples[i].point.longitude);
final key = '${t.tx}_${t.ty}';
tileGroups.putIfAbsent(key, () => []).add(i);
tileCoords[key] = (t.tx, t.ty);
pixelForIndex[i] = (t.px, t.py);
}
final elevations = List<double?>.filled(samples.length, null);
await Future.wait(tileGroups.entries.map((entry) async {
final (tx, ty) = tileCoords[entry.key]!;
final rgba = await _fetchTileRgba(tx, ty);
if (rgba == null) return;
for (final idx in entry.value) {
final (px, py) = pixelForIndex[idx]!;
elevations[idx] = _elevationAt(rgba, px, py);
}
}));
final validIdx = elevations.indexWhere((e) => e != null);
if (validIdx == -1) return null;
final home = elevations[validIdx]!;
return TerrainProfile(
routeKey: routeKeyForTerrain(waypoints),
points: [
for (var i = 0; i < samples.length; i++)
TerrainSamplePoint(
distanceM: samples[i].distanceM,
elevationRelM: elevations[i] != null ? elevations[i]! - home : null,
),
],
);
}
}
@@ -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);
+104 -20
View File
@@ -3,16 +3,13 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' hide Path; import 'package:latlong2/latlong.dart' hide Path;
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart'; import '../../domain/waypoint/flat_waypoint_list.dart';
/// Vollbild-Chart zum Anpassen von Hoehe/Geschwindigkeit per Drag auf den /// Vollbild-Chart zum Anpassen von Hoehe/Geschwindigkeit per Drag auf den
/// Punkten (HTML-Demonstrator: buildFullChart()/attachChartDrag()). Jeder /// Punkten (HTML-Demonstrator: buildFullChart()/attachChartDrag()). Jeder
/// Punkt entspricht einem Wegpunkt, X-Position proportional zur /// Punkt entspricht einem Wegpunkt, X-Position proportional zur
/// zurueckgelegten Distanz entlang der Route. /// 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 { class FullValueChart extends StatefulWidget {
const FullValueChart({ const FullValueChart({
super.key, super.key,
@@ -29,6 +26,9 @@ class FullValueChart extends StatefulWidget {
required this.onChanged, required this.onChanged,
this.legWind, this.legWind,
this.groundSpeeds, this.groundSpeeds,
this.terrainPoints,
this.terrainDangerRanges,
this.terrainLoading = false,
}); });
final List<Waypoint> waypoints; final List<Waypoint> waypoints;
@@ -51,6 +51,17 @@ class FullValueChart extends StatefulWidget {
/// Laenge/Null-Semantik wie [legWind]. /// Laenge/Null-Semantik wie [legWind].
final List<double?>? groundSpeeds; 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 const _distance = Distance(roundResult: false);
static List<double> cumulativeDistances(List<Waypoint> waypoints) { static List<double> cumulativeDistances(List<Waypoint> waypoints) {
@@ -80,6 +91,8 @@ class _FullValueChartState extends State<FullValueChart> {
...widget.values, ...widget.values,
for (final v in widget.legWind ?? const <double?>[]) ?v, for (final v in widget.legWind ?? const <double?>[]) ?v,
for (final v in widget.groundSpeeds ?? 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); if (allVals.isEmpty) return (lo: widget.min, hi: widget.max);
final dataMin = allVals.reduce(math.min); final dataMin = allVals.reduce(math.min);
@@ -160,21 +173,36 @@ class _FullValueChartState extends State<FullValueChart> {
}, },
onPanEnd: (_) => _dragIndex = null, onPanEnd: (_) => _dragIndex = null,
onPanCancel: () => _dragIndex = null, onPanCancel: () => _dragIndex = null,
child: CustomPaint( child: Stack(
size: size, children: [
painter: _ChartPainter( CustomPaint(
waypoints: widget.waypoints, size: size,
values: widget.values, painter: _ChartPainter(
cumDist: cumDist, waypoints: widget.waypoints,
legBad: widget.legBad, values: widget.values,
vertexBad: widget.vertexBad, cumDist: cumDist,
activeIndex: widget.activeIndex, legBad: widget.legBad,
lo: range.lo, vertexBad: widget.vertexBad,
hi: range.hi, activeIndex: widget.activeIndex,
unit: widget.unit, lo: range.lo,
legWind: widget.legWind, hi: range.hi,
groundSpeeds: widget.groundSpeeds, 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, required this.unit,
this.legWind, this.legWind,
this.groundSpeeds, this.groundSpeeds,
this.terrainPoints,
this.terrainDangerRanges,
}); });
final List<Waypoint> waypoints; final List<Waypoint> waypoints;
@@ -208,6 +238,8 @@ class _ChartPainter extends CustomPainter {
final String unit; final String unit;
final List<double?>? legWind; final List<double?>? legWind;
final List<double?>? groundSpeeds; final List<double?>? groundSpeeds;
final List<TerrainSamplePoint>? terrainPoints;
final List<(double, double)>? terrainDangerRanges;
static const _padX = 50.0; static const _padX = 50.0;
static const _padY = 20.0; static const _padY = 20.0;
@@ -219,6 +251,11 @@ class _ChartPainter extends CustomPainter {
return _padX + (cumDist[idx] / total) * (boxW - 2 * _padX); 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) => double _yAt(double val, double boxH) =>
boxH - _padY - ((val - lo) / (hi - lo)) * (boxH - 2 * _padY); boxH - _padY - ((val - lo) / (hi - lo)) * (boxH - 2 * _padY);
@@ -239,6 +276,10 @@ class _ChartPainter extends CustomPainter {
final boxH = size.height; final boxH = size.height;
final n = waypoints.length; 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. // Gridlines mit gerundeten Werten.
final step = _niceStep(hi - lo, 5); final step = _niceStep(hi - lo, 5);
final gridPaint = Paint() final gridPaint = Paint()
@@ -262,6 +303,7 @@ class _ChartPainter extends CustomPainter {
v += step; v += step;
} }
_paintTerrain(canvas, boxW, boxH);
_paintWindAndGroundSpeed(canvas, boxW, boxH); _paintWindAndGroundSpeed(canvas, boxW, boxH);
// Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate). // 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 /// Wind-Diamanten je Leg (Kopf-/Rueckenwind-Komponente) und die daraus
/// erreichbare Bodengeschwindigkeit als gestrichelte Stufenlinie - nur auf /// erreichbare Bodengeschwindigkeit als gestrichelte Stufenlinie - nur auf
/// dem Speed-Tab genutzt (Doku 3.8, windHtml/groundSpeedHtml im HTML- /// dem Speed-Tab genutzt (Doku 3.8, windHtml/groundSpeedHtml im HTML-
@@ -426,5 +508,7 @@ class _ChartPainter extends CustomPainter {
oldDelegate.lo != lo || oldDelegate.lo != lo ||
oldDelegate.hi != hi || oldDelegate.hi != hi ||
oldDelegate.legWind != legWind || oldDelegate.legWind != legWind ||
oldDelegate.groundSpeeds != groundSpeeds; oldDelegate.groundSpeeds != groundSpeeds ||
oldDelegate.terrainPoints != terrainPoints ||
oldDelegate.terrainDangerRanges != terrainDangerRanges;
} }
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/default_drone_profile.dart'; import '../../domain/mission/default_drone_profile.dart';
import '../../domain/mission/drone_profile.dart'; import '../../domain/mission/drone_profile.dart';
import '../../domain/mission/route_geometry.dart'; import '../../domain/mission/route_geometry.dart';
import '../../domain/mission/terrain_math.dart';
import '../../domain/waypoint/flat_waypoint_list.dart'; import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart'; import '../../domain/wind/wind_math.dart';
import '../providers/active_drone_profile_provider.dart'; import '../providers/active_drone_profile_provider.dart';
import '../providers/current_mission_provider.dart'; import '../providers/current_mission_provider.dart';
import '../providers/terrain_provider.dart';
import '../providers/wind_provider.dart'; import '../providers/wind_provider.dart';
import 'full_value_chart.dart'; import 'full_value_chart.dart';
import 'waypoint_chip.dart'; import 'waypoint_chip.dart';
@@ -74,6 +76,16 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive()); 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 @override
@@ -137,6 +149,7 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider); final waypoints = ref.watch(currentMissionProvider);
final droneProfile = ref.watch(activeDroneProfileProvider); final droneProfile = ref.watch(activeDroneProfileProvider);
final terrainState = ref.watch(terrainProvider);
final routeGeometry = buildRouteGeometry( final routeGeometry = buildRouteGeometry(
waypoints, waypoints,
minTurnRadius: droneProfile.minTurnRadius, minTurnRadius: droneProfile.minTurnRadius,
@@ -204,6 +217,11 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
pxPerUnit: 5, pxPerUnit: 5,
unit: ' m', unit: ' m',
onChanged: widget.onAltitudeChanged, onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
), ),
_PanelTab.speed => _chartTab( _PanelTab.speed => _chartTab(
waypoints, waypoints,
@@ -234,6 +252,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
setState(() => _tab = tab); setState(() => _tab = tab);
if (tab == _PanelTab.list) { if (tab == _PanelTab.list) {
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive()); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActive());
} else if (tab == _PanelTab.altitude) {
ref.read(terrainProvider.notifier).ensureFor(ref.read(currentMissionProvider));
} }
}, },
child: Container( child: Container(
@@ -272,6 +292,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
required void Function(int index, double value) onChanged, required void Function(int index, double value) onChanged,
List<double?>? legWind, List<double?>? legWind,
List<double?>? groundSpeeds, List<double?>? groundSpeeds,
List<TerrainSamplePoint>? terrainPoints,
List<(double, double)>? terrainDangerRanges,
bool terrainLoading = false,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8), padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
@@ -289,6 +312,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
onChanged: onChanged, onChanged: onChanged,
legWind: legWind, legWind: legWind,
groundSpeeds: groundSpeeds, groundSpeeds: groundSpeeds,
terrainPoints: terrainPoints,
terrainDangerRanges: terrainDangerRanges,
terrainLoading: terrainLoading,
), ),
); );
} }
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:dmc_app/domain/mission/terrain_math.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
void main() {
group('routeKeyForTerrain', () {
test('aendert sich, wenn sich ein Wegpunkt verschiebt', () {
const a = [
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
const b = [
Waypoint(lat: 52.001, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
expect(routeKeyForTerrain(a), isNot(routeKeyForTerrain(b)));
});
test('bleibt gleich bei reinen Wertaenderungen (Hoehe/Geschwindigkeit)', () {
const a = [
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
const b = [
Waypoint(lat: 52.0, lon: 4.0, altitudeM: 120, speedMs: 20, catchRadiusM: 60),
];
expect(routeKeyForTerrain(a), routeKeyForTerrain(b));
});
});
group('sampleCountFor', () {
test('klemmt sehr kurze Routen auf mindestens 10 Samples', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0.0001, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
expect(sampleCountFor(waypoints), 10);
});
test('folgt der 30-m-Sampling-Distanz fuer laengere Routen', () {
// 1 Grad Breite ~ 111.320 km -> 0.01 Grad ~ 1113 m.
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0.01, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
final count = sampleCountFor(waypoints);
expect(count, greaterThan(10));
expect(count, closeTo(1113 / terrainSampleSpacingM, 2));
});
test('liefert 0 fuer eine einzelne Wegpunkt-Route', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
expect(sampleCountFor(waypoints), 0);
});
});
group('sampleRouteByDistance', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0, lon: 0.01, altitudeM: 80, speedMs: 15, catchRadiusM: 60),
];
test('erstes und letztes Sample entsprechen den Wegpunkten', () {
final samples = sampleRouteByDistance(waypoints, 10);
expect(samples, hasLength(11));
expect(samples.first.distanceM, 0);
expect(samples.first.point.latitude, closeTo(0, 1e-9));
expect(samples.first.point.longitude, closeTo(0, 1e-9));
expect(samples.last.point.longitude, closeTo(0.01, 1e-9));
});
test('Samples sind gleichmaessig nach Distanz verteilt', () {
final samples = sampleRouteByDistance(waypoints, 4);
final total = samples.last.distanceM;
for (var i = 0; i < samples.length; i++) {
expect(samples[i].distanceM, closeTo(total * i / 4, 1e-6));
}
});
});
group('plannedAltitudeAt', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0, lon: 0.01, altitudeM: 120, speedMs: 15, catchRadiusM: 60),
];
test('interpoliert linear zwischen zwei Wegpunkten', () {
final total = sampleRouteByDistance(waypoints, 1).last.distanceM;
expect(plannedAltitudeAt(waypoints, total / 2), closeTo(90, 0.5));
});
test('klemmt vor dem ersten und nach dem letzten Wegpunkt', () {
expect(plannedAltitudeAt(waypoints, -100), 60);
final total = sampleRouteByDistance(waypoints, 1).last.distanceM;
expect(plannedAltitudeAt(waypoints, total + 1000), 120);
});
});
group('terrainDangerRanges', () {
const waypoints = [
Waypoint(lat: 0, lon: 0, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
Waypoint(lat: 0, lon: 0.01, altitudeM: 60, speedMs: 15, catchRadiusM: 60),
];
test('meldet keinen gefaehrlichen Bereich bei ausreichendem Abstand', () {
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: 0),
TerrainSamplePoint(distanceM: 500, elevationRelM: 0),
TerrainSamplePoint(distanceM: 1000, elevationRelM: 0),
]);
expect(terrainDangerRanges(waypoints, profile), isEmpty);
});
test('markiert den Bereich, in dem der Bodenabstand unterschritten wird', () {
// Geplante Hoehe konstant 60m; Clearance = 60 - elevRel.
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: 0), // clearance 60
TerrainSamplePoint(distanceM: 300, elevationRelM: 55), // clearance 5 (< 10)
TerrainSamplePoint(distanceM: 600, elevationRelM: 52), // clearance 8 (< 10)
TerrainSamplePoint(distanceM: 900, elevationRelM: 0), // clearance 60
]);
final ranges = terrainDangerRanges(waypoints, profile);
expect(ranges, [(300.0, 900.0)]);
});
test('ignoriert Samples ohne geladene Hoehe', () {
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: null),
TerrainSamplePoint(distanceM: 500),
]);
expect(terrainDangerRanges(waypoints, profile), isEmpty);
});
test('schliesst einen bis zum letzten Sample andauernden Bereich ab', () {
final profile = TerrainProfile(routeKey: 'k', points: const [
TerrainSamplePoint(distanceM: 0, elevationRelM: 55), // clearance 5
TerrainSamplePoint(distanceM: 500, elevationRelM: 55),
]);
expect(terrainDangerRanges(waypoints, profile), [(0.0, 500.0)]);
});
});
}
@@ -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);
});
}