From b176ad294e108f057b1514a77247ce09af699a89 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:38:23 +0200 Subject: [PATCH] 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 --- app/lib/domain/mission/terrain_math.dart | 159 ++++++++++++++++++ app/lib/services/terrain/terrain_service.dart | 120 +++++++++++++ app/lib/ui/providers/terrain_provider.dart | 54 ++++++ app/lib/ui/widgets/full_value_chart.dart | 124 +++++++++++--- app/lib/ui/widgets/waypoint_list_panel.dart | 26 +++ app/test/domain/terrain_math_test.dart | 151 +++++++++++++++++ app/test/services/terrain_service_test.dart | 78 +++++++++ 7 files changed, 692 insertions(+), 20 deletions(-) create mode 100644 app/lib/domain/mission/terrain_math.dart create mode 100644 app/lib/services/terrain/terrain_service.dart create mode 100644 app/lib/ui/providers/terrain_provider.dart create mode 100644 app/test/domain/terrain_math_test.dart create mode 100644 app/test/services/terrain_service_test.dart diff --git a/app/lib/domain/mission/terrain_math.dart b/app/lib/domain/mission/terrain_math.dart new file mode 100644 index 0000000..8117c05 --- /dev/null +++ b/app/lib/domain/mission/terrain_math.dart @@ -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 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 _cumulativeDistances(List waypoints) { + final cum = [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 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 sampleRouteByDistance( + List 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 = []; + 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 points; +} + +/// Lineare Interpolation der geplanten (Soll-)Hoehe an einer beliebigen +/// Streckenposition - analog plannedAltAt() im HTML-Demonstrator. +double plannedAltitudeAt(List 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 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; +} diff --git a/app/lib/services/terrain/terrain_service.dart b/app/lib/services/terrain/terrain_service.dart new file mode 100644 index 0000000..c911355 --- /dev/null +++ b/app/lib/services/terrain/terrain_service.dart @@ -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 _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 fetchProfile(List waypoints) async { + if (waypoints.length < 2) return null; + + final numSamples = sampleCountFor(waypoints); + final samples = sampleRouteByDistance(waypoints, numSamples); + if (samples.isEmpty) return null; + + final tileGroups = >{}; + final tileCoords = {}; + final pixelForIndex = {}; + 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.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, + ), + ], + ); + } +} diff --git a/app/lib/ui/providers/terrain_provider.dart b/app/lib/ui/providers/terrain_provider.dart new file mode 100644 index 0000000..cb032b5 --- /dev/null +++ b/app/lib/ui/providers/terrain_provider.dart @@ -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((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 { + @override + TerrainState build() => const TerrainState(); + + Future ensureFor(List 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.new); diff --git a/app/lib/ui/widgets/full_value_chart.dart b/app/lib/ui/widgets/full_value_chart.dart index 6940369..3c74e65 100644 --- a/app/lib/ui/widgets/full_value_chart.dart +++ b/app/lib/ui/widgets/full_value_chart.dart @@ -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 waypoints; @@ -51,6 +51,17 @@ class FullValueChart extends StatefulWidget { /// Laenge/Null-Semantik wie [legWind]. final List? groundSpeeds; + /// Gelaendeprofil entlang der Route (nur Altitude-Tab, Doku 3.9), relativ + /// zum ersten gueltigen Sample - null, solange noch nicht geladen. + final List? 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 cumulativeDistances(List waypoints) { @@ -80,6 +91,8 @@ class _FullValueChartState extends State { ...widget.values, for (final v in widget.legWind ?? const []) ?v, for (final v in widget.groundSpeeds ?? const []) ?v, + for (final p in widget.terrainPoints ?? const []) + ?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 { }, 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 waypoints; @@ -208,6 +238,8 @@ class _ChartPainter extends CustomPainter { final String unit; final List? legWind; final List? groundSpeeds; + final List? 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; } diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index 0c8b463..179302b 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -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 { 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 { 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 { 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 { 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 { required void Function(int index, double value) onChanged, List? legWind, List? groundSpeeds, + List? 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 { onChanged: onChanged, legWind: legWind, groundSpeeds: groundSpeeds, + terrainPoints: terrainPoints, + terrainDangerRanges: terrainDangerRanges, + terrainLoading: terrainLoading, ), ); } diff --git a/app/test/domain/terrain_math_test.dart b/app/test/domain/terrain_math_test.dart new file mode 100644 index 0000000..ea8a3dd --- /dev/null +++ b/app/test/domain/terrain_math_test.dart @@ -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)]); + }); + }); +} diff --git a/app/test/services/terrain_service_test.dart b/app/test/services/terrain_service_test.dart new file mode 100644 index 0000000..79010dd --- /dev/null +++ b/app/test/services/terrain_service_test.dart @@ -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 _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.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); + }); +}