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: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,7 +173,9 @@ class _FullValueChartState extends State<FullValueChart> {
|
|||||||
},
|
},
|
||||||
onPanEnd: (_) => _dragIndex = null,
|
onPanEnd: (_) => _dragIndex = null,
|
||||||
onPanCancel: () => _dragIndex = null,
|
onPanCancel: () => _dragIndex = null,
|
||||||
child: CustomPaint(
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
size: size,
|
size: size,
|
||||||
painter: _ChartPainter(
|
painter: _ChartPainter(
|
||||||
waypoints: widget.waypoints,
|
waypoints: widget.waypoints,
|
||||||
@@ -174,8 +189,21 @@ class _FullValueChartState extends State<FullValueChart> {
|
|||||||
unit: widget.unit,
|
unit: widget.unit,
|
||||||
legWind: widget.legWind,
|
legWind: widget.legWind,
|
||||||
groundSpeeds: widget.groundSpeeds,
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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