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