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:
co-authored by
Claude Sonnet 5
parent
5505bb3741
commit
b176ad294e
@@ -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);
|
||||
@@ -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,7 +173,9 @@ class _FullValueChartState extends State<FullValueChart> {
|
||||
},
|
||||
onPanEnd: (_) => _dragIndex = null,
|
||||
onPanCancel: () => _dragIndex = null,
|
||||
child: CustomPaint(
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: size,
|
||||
painter: _ChartPainter(
|
||||
waypoints: widget.waypoints,
|
||||
@@ -174,8 +189,21 @@ class _FullValueChartState extends State<FullValueChart> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<WaypointListPanel> {
|
||||
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<WaypointListPanel> {
|
||||
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<WaypointListPanel> {
|
||||
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<WaypointListPanel> {
|
||||
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<WaypointListPanel> {
|
||||
required void Function(int index, double value) onChanged,
|
||||
List<double?>? legWind,
|
||||
List<double?>? groundSpeeds,
|
||||
List<TerrainSamplePoint>? 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<WaypointListPanel> {
|
||||
onChanged: onChanged,
|
||||
legWind: legWind,
|
||||
groundSpeeds: groundSpeeds,
|
||||
terrainPoints: terrainPoints,
|
||||
terrainDangerRanges: terrainDangerRanges,
|
||||
terrainLoading: terrainLoading,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user