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 waypoints; final List values; final List legBad; final List 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? legWind; /// Erreichbare Bodengeschwindigkeit pro Leg (nur Speed-Tab), gleiche /// Laenge/Null-Semantik wie [legWind]. final List? groundSpeeds; static const _distance = Distance(roundResult: false); static List cumulativeDistances(List waypoints) { final cum = [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 createState() => _FullValueChartState(); } class _FullValueChartState extends State { 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 = [ ...widget.values, for (final v in widget.legWind ?? const []) ?v, for (final v in widget.groundSpeeds ?? const []) ?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 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 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 waypoints; final List values; final List cumDist; final List legBad; final List vertexBad; final int activeIndex; final double lo; final double hi; final String unit; final List? legWind; final List? 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 = []; 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; }