Files
dmc/app/lib/ui/widgets/full_value_chart.dart
T
Constantin LeueandClaude Sonnet 5 1b13c45cac Add wind analysis: header pill, fetch/validate menu, speed-plot overlay
Portiert das Wind-System des HTML-Demonstrators (Doku 3.8) vollstaendig:
Open-Meteo-Winddaten, interpoliert zwischen Druckflaechen- und festen
Nabenhoehen-Stuetzpunkten auf die tatsaechliche Zielhoehe.

Domain (lib/domain/wind/wind_math.dart, reines Dart): Hoehen-
Interpolation (linear + zirkulaer fuer Windrichtung), Peilung zwischen
zwei Punkten, Kopf-/Rueckenwind-Komponente pro Flugleg - 1:1 uebernommen
aus interpolateWindAtHeight()/bearingBetween()/headTailwindComponent()
des Demonstrators, per Unit-Tests abgesichert.

Service (lib/services/wind/wind_service.dart): zwei getrennte,
fokussierte Open-Meteo-Requests statt eines kombinierten (Doku 4.10 -
ein 35-Variablen-Request verursachte im Demonstrator Modellauswahl-
Fehler). Drei Verbraucher: Kopfleisten-Pille (120 m AGL ueber
Referenzort), gebuendelter Wegpunkt-Fetch (ein Request fuer alle
Koordinaten via Open-Meteos Mehrfach-Location-Syntax) und das
Validierungspanel (rohe Messwerte je Nabenhoehe/Druckflaeche). Per
MockClient getestet (kein echter Netzwerkzugriff in Tests).

UI:
- HeaderWindPill (TopModeBar, nur ausserhalb Fly-Modus): zeigt
  Windrichtung/-geschwindigkeit, Antippen schaltet perspektivisch die
  Wind-Marker der Wegpunkte um (Zustand vorbereitet, HTML-Demonstrator:
  showPerWaypointWind).
- WaypointListPanel: "Fetch wind"/"Validate"-Buttons im Panel-Header,
  neue WIND-Spalte in der Liste.
- WindValidatePanel: neue Vollbild-Route (gleiches Muster wie
  WaypointListPanel) mit Rohdaten je Hoehenstufe inkl. Boeen (Doku 4.9:
  Boeen nur hier, nicht in der Wegpunktanzeige) und Interpolationsergebnis.
- FullValueChart (Speed-Tab): Kopf-/Rueckenwind-Diamanten pro Leg samt
  gestrichelter Nulllinie sowie gestrichelte Bodengeschwindigkeits-
  Stufenlinie.

Bug gefunden und gefixt waehrend der Emulator-Verifikation: die
Y-Achsen-Autoskalierung klemmte weiterhin auf den gueltigen Drehrad-
Wertebereich (13-25 m/s), wodurch nahe Null liegende Windkomponenten
(bei schwachem Wind der Normalfall) ausserhalb des sichtbaren Bereichs
lagen und weder Diamanten noch Nulllinie zu sehen waren. Der HTML-
Demonstrator klemmt computeSpeedChartRange() bewusst nicht auf SPD_MIN/
SPD_MAX; das Klemmen in FullValueChart._range() jetzt entsprechend
entfernt.

Waypoint-Modell um windSpeedMs/windDirFromDeg/windElevationM erweitert
(nullable, reine Planungshilfe - der Encoder liest diese Felder nie,
Doku 3.2). currentMissionProvider bekommt replaceAll() fuer den
gebuendelten Wind-Fetch, der alle Wegpunkte gleichzeitig aktualisiert.

Verifiziert: flutter analyze (0 issues), flutter test (44/44, davon 14
neue Wind-Mathematik- und 6 neue WindService-Tests), manuell auf
Pixel_10a-Emulator mit echtem Open-Meteo-Netzwerkzugriff - Kopfleisten-
Pille zeigt Live-Wind, Fetch/Validate fuellen Liste bzw. oeffnen das
Validierungspanel mit echten Messwerten, Speed-Plot zeigt nach dem Fix
Diamanten/Nulllinie/Bodengeschwindigkeit korrekt skaliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:15 +02:00

431 lines
14 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' hide Path;
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,
required this.waypoints,
required this.values,
required this.legBad,
required this.vertexBad,
required this.activeIndex,
required this.min,
required this.max,
required this.step,
required this.pxPerUnit,
required this.unit,
required this.onChanged,
this.legWind,
this.groundSpeeds,
});
final List<Waypoint> waypoints;
final List<double> values;
final List<bool> legBad;
final List<bool> vertexBad;
final int activeIndex;
final double min;
final double max;
final double step;
final double pxPerUnit;
final String unit;
final void Function(int index, double value) onChanged;
/// Kopf-/Rueckenwind-Komponente pro Leg (nur Speed-Tab, Doku 3.8) -
/// Laenge waypoints.length - 1, null wo noch kein Wind geladen ist.
final List<double?>? legWind;
/// Erreichbare Bodengeschwindigkeit pro Leg (nur Speed-Tab), gleiche
/// Laenge/Null-Semantik wie [legWind].
final List<double?>? groundSpeeds;
static const _distance = Distance(roundResult: false);
static List<double> cumulativeDistances(List<Waypoint> waypoints) {
final cum = <double>[0];
for (var i = 1; i < waypoints.length; i++) {
final a = LatLng(waypoints[i - 1].lat, waypoints[i - 1].lon);
final b = LatLng(waypoints[i].lat, waypoints[i].lon);
cum.add(cum[i - 1] + _distance(a, b));
}
return cum;
}
@override
State<FullValueChart> createState() => _FullValueChartState();
}
class _FullValueChartState extends State<FullValueChart> {
int? _dragIndex;
double _dragStartY = 0;
double _dragStartValue = 0;
static const _padX = 50.0;
static const _hitToleranceX = 28.0;
({double lo, double hi}) _range() {
final allVals = <double>[
...widget.values,
for (final v in widget.legWind ?? const <double?>[]) ?v,
for (final v in widget.groundSpeeds ?? const <double?>[]) ?v,
];
if (allVals.isEmpty) return (lo: widget.min, hi: widget.max);
final dataMin = allVals.reduce(math.min);
final dataMax = allVals.reduce(math.max);
final span = dataMax - dataMin;
final pad = math.max(span * 0.35, widget.step * 2);
var lo = dataMin - pad;
var hi = dataMax + pad;
final minSpan = widget.step * 6;
if (hi - lo < minSpan) {
final mid = (lo + hi) / 2;
lo = mid - minSpan / 2;
hi = mid + minSpan / 2;
}
// Nicht auf widget.min/max (gueltiger Drohnen-Geschwindigkeitsbereich)
// klemmen: Windkomponenten/Bodengeschwindigkeit (Doku 3.8) sind keine
// gueltigen Soll-Geschwindigkeiten und duerfen den sichtbaren Bereich
// durchaus unter min bzw. ueber max ziehen (HTML-Demonstrator:
// computeSpeedChartRange() klemmt ebenfalls nicht auf SPD_MIN/SPD_MAX).
if (hi <= lo) {
lo = widget.min;
hi = widget.max;
}
return (lo: lo, hi: hi);
}
double _xAt(int idx, List<double> cumDist, double boxW) {
final n = widget.waypoints.length;
if (n <= 1) return boxW / 2;
final total = cumDist.last == 0 ? 1.0 : cumDist.last;
return _padX + (cumDist[idx] / total) * (boxW - 2 * _padX);
}
int? _nearestIndex(Offset local, List<double> cumDist, double boxW) {
var best = -1;
var bestDist = double.infinity;
for (var i = 0; i < widget.waypoints.length; i++) {
final dx = (_xAt(i, cumDist, boxW) - local.dx).abs();
if (dx < bestDist) {
bestDist = dx;
best = i;
}
}
return bestDist <= _hitToleranceX ? best : null;
}
@override
Widget build(BuildContext context) {
if (widget.waypoints.isEmpty) {
return const Center(
child: Text('No waypoints yet', style: TextStyle(color: Colors.white60)),
);
}
final cumDist = FullValueChart.cumulativeDistances(widget.waypoints);
final range = _range();
return LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
return GestureDetector(
onPanStart: (details) {
final idx = _nearestIndex(details.localPosition, cumDist, size.width);
if (idx == null) return;
_dragIndex = idx;
_dragStartY = details.localPosition.dy;
_dragStartValue = widget.values[idx];
},
onPanUpdate: (details) {
final idx = _dragIndex;
if (idx == null) return;
final deltaY = _dragStartY - details.localPosition.dy;
final deltaVal = deltaY / widget.pxPerUnit;
var val = _dragStartValue + deltaVal;
val = (val / widget.step).round() * widget.step;
val = val.clamp(widget.min, widget.max);
widget.onChanged(idx, val);
},
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,
),
),
);
},
);
}
}
class _ChartPainter extends CustomPainter {
const _ChartPainter({
required this.waypoints,
required this.values,
required this.cumDist,
required this.legBad,
required this.vertexBad,
required this.activeIndex,
required this.lo,
required this.hi,
required this.unit,
this.legWind,
this.groundSpeeds,
});
final List<Waypoint> waypoints;
final List<double> values;
final List<double> cumDist;
final List<bool> legBad;
final List<bool> vertexBad;
final int activeIndex;
final double lo;
final double hi;
final String unit;
final List<double?>? legWind;
final List<double?>? groundSpeeds;
static const _padX = 50.0;
static const _padY = 20.0;
double _xAt(int idx, double boxW) {
final n = waypoints.length;
if (n <= 1) return boxW / 2;
final total = cumDist.last == 0 ? 1.0 : cumDist.last;
return _padX + (cumDist[idx] / total) * (boxW - 2 * _padX);
}
double _yAt(double val, double boxH) =>
boxH - _padY - ((val - lo) / (hi - lo)) * (boxH - 2 * _padY);
double _niceStep(double range, int targetTicks) {
if (range <= 0) return 1;
final rough = range / targetTicks;
final mag = math.pow(10, (math.log(rough) / math.ln10).floor()).toDouble();
final norm = rough / mag;
if (norm < 1.5) return mag;
if (norm < 3) return 2 * mag;
if (norm < 7) return 5 * mag;
return 10 * mag;
}
@override
void paint(Canvas canvas, Size size) {
final boxW = size.width;
final boxH = size.height;
final n = waypoints.length;
// Gridlines mit gerundeten Werten.
final step = _niceStep(hi - lo, 5);
final gridPaint = Paint()
..color = Colors.white.withValues(alpha: 0.14)
..strokeWidth = 1;
final axisPaint = Paint()
..color = Colors.white.withValues(alpha: 0.35)
..strokeWidth = 1.5;
canvas.drawLine(Offset(_padX, 0), Offset(_padX, boxH), axisPaint);
final labelStyle = TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 10);
var v = (lo / step).ceil() * step;
while (v <= hi + 1e-6) {
final y = _yAt(v, boxH);
canvas.drawLine(Offset(_padX, y), Offset(boxW - _padX, y), gridPaint);
final tp = TextPainter(
text: TextSpan(text: '${v.round()}$unit', style: labelStyle),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(_padX - 8 - tp.width, y - tp.height / 2));
v += step;
}
_paintWindAndGroundSpeed(canvas, boxW, boxH);
// Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate).
for (var i = 0; i < n - 1; i++) {
final bad = i < legBad.length && legBad[i];
canvas.drawLine(
Offset(_xAt(i, boxW), _yAt(values[i], boxH)),
Offset(_xAt(i + 1, boxW), _yAt(values[i + 1], boxH)),
Paint()
..color = bad ? const Color(0xFFE2574A) : Colors.white
..strokeWidth = 2.5,
);
}
// Punkte (blau=aktiver Zielpunkt, rot=nicht fliegbar, sonst weiss).
for (var i = 0; i < n; i++) {
final active = i == activeIndex;
final bad = i < vertexBad.length && vertexBad[i];
final color =
active ? const Color(0xFF4D9DFF) : (bad ? const Color(0xFFE2574A) : Colors.white);
final center = Offset(_xAt(i, boxW), _yAt(values[i], boxH));
canvas.drawCircle(center, active ? 9 : 7, Paint()..color = color);
canvas.drawCircle(
center,
active ? 9 : 7,
Paint()
..color = const Color(0xFF2F6FE0)
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
}
}
/// 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-
/// Demonstrator). Vor den eigentlichen Soll-Werte-Punkten gezeichnet,
/// damit diese immer obenauf liegen.
void _paintWindAndGroundSpeed(Canvas canvas, double boxW, double boxH) {
final wind = legWind;
if (wind != null && wind.any((v) => v != null)) {
final wZeroY = _yAt(0, boxH);
_drawDashedLine(
canvas,
Offset(_padX, wZeroY),
Offset(boxW - _padX, wZeroY),
Paint()
..color = Colors.white.withValues(alpha: 0.4)
..strokeWidth = 1.5,
dashWidth: 3,
gapWidth: 4,
);
final zeroLabel = TextPainter(
text: TextSpan(
text: '0 (no wind effect on track)',
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 10),
),
textDirection: TextDirection.ltr,
)..layout();
zeroLabel.paint(canvas, Offset(_padX, wZeroY - 5 - zeroLabel.height));
final windPts = <Offset>[];
for (var i = 0; i < wind.length; i++) {
final w = wind[i];
if (w == null) continue;
final mx = (_xAt(i, boxW) + _xAt(i + 1, boxW)) / 2;
final my = _yAt(w, boxH);
windPts.add(Offset(mx, my));
final isTail = w >= 0;
final color = isTail ? const Color(0xFF3ECF8E) : const Color(0xFFE2574A);
final diamond = Path()
..moveTo(mx, my - 7)
..lineTo(mx + 7, my)
..lineTo(mx, my + 7)
..lineTo(mx - 7, my)
..close();
canvas.drawPath(diamond, Paint()..color = color);
canvas.drawPath(
diamond,
Paint()
..color = const Color(0xFF0C0C0C)
..style = PaintingStyle.stroke
..strokeWidth = 1,
);
final valueLabel = TextPainter(
text: TextSpan(
text: '${isTail ? '+' : ''}${w.toStringAsFixed(1)}',
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600),
),
textDirection: TextDirection.ltr,
)..layout();
valueLabel.paint(
canvas,
Offset(mx - valueLabel.width / 2, my - 12 - valueLabel.height),
);
}
final connectPaint = Paint()
..color = Colors.white.withValues(alpha: 0.25)
..strokeWidth = 1;
for (var i = 0; i < windPts.length - 1; i++) {
_drawDashedLine(canvas, windPts[i], windPts[i + 1], connectPaint,
dashWidth: 2, gapWidth: 3);
}
}
final ground = groundSpeeds;
if (ground != null) {
double? prevLevelY;
final groundPaint = Paint()
..color = const Color(0xFF94BBE9).withValues(alpha: 0.55)
..strokeWidth = 1.5;
for (var i = 0; i < ground.length; i++) {
final g = ground[i];
if (g == null) {
prevLevelY = null;
continue;
}
final gy = _yAt(g, boxH);
final gx0 = _xAt(i, boxW);
final gx1 = _xAt(i + 1, boxW);
_drawDashedLine(canvas, Offset(gx0, gy), Offset(gx1, gy), groundPaint,
dashWidth: 5, gapWidth: 3);
if (prevLevelY != null) {
_drawDashedLine(canvas, Offset(gx0, prevLevelY), Offset(gx0, gy),
groundPaint, dashWidth: 5, gapWidth: 3);
}
prevLevelY = gy;
}
}
}
void _drawDashedLine(
Canvas canvas,
Offset p1,
Offset p2,
Paint paint, {
double dashWidth = 4,
double gapWidth = 3,
}) {
final total = (p2 - p1).distance;
if (total == 0) return;
final direction = (p2 - p1) / total;
var traveled = 0.0;
while (traveled < total) {
final segEnd = math.min(traveled + dashWidth, total);
canvas.drawLine(
p1 + direction * traveled,
p1 + direction * segEnd,
paint,
);
traveled += dashWidth + gapWidth;
}
}
@override
bool shouldRepaint(covariant _ChartPainter oldDelegate) =>
oldDelegate.values != values ||
oldDelegate.activeIndex != activeIndex ||
oldDelegate.lo != lo ||
oldDelegate.hi != hi ||
oldDelegate.legWind != legWind ||
oldDelegate.groundSpeeds != groundSpeeds;
}