import 'dart:math' as math; import 'dart:ui' show Offset; /// The HUD's pure geometry, kept out of the painters so it can be unit-tested /// (plan §4: "attitude-ball geometry (bearing/distance → dot position)"). abstract final class HudGeometry { static const double _earthRadius = 6371000; // m static double _rad(double deg) => deg * math.pi / 180; /// Great-circle distance in metres. static double distance(double lat1, double lon1, double lat2, double lon2) { final double dLat = _rad(lat2 - lat1), dLon = _rad(lon2 - lon1); final double h = math.pow(math.sin(dLat / 2), 2) + math.cos(_rad(lat1)) * math.cos(_rad(lat2)) * math.pow(math.sin(dLon / 2), 2); return 2 * _earthRadius * math.asin(math.min(1, math.sqrt(h))); } /// Initial bearing from point 1 to point 2, degrees clockwise from north, 0…360. static double bearing(double lat1, double lon1, double lat2, double lon2) { final double p1 = _rad(lat1), p2 = _rad(lat2), dLon = _rad(lon2 - lon1); final double y = math.sin(dLon) * math.cos(p2); final double x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dLon); return (math.atan2(y, x) * 180 / math.pi + 360) % 360; } /// The attitude ball's distance scales, in metres from centre to rim. static const List ballRanges = [25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; /// The smallest scale that fits [farthest] on the ball; the largest when nothing does. static double ballRange(double farthest) { for (final double r in ballRanges) { if (farthest <= r) return r; } return ballRanges.last; } /// Where a point [distanceM] away at [bearingDeg] lands on a ball of [radius] /// px scaled to [rangeM], relative to the centre, with screen y pointing down. /// [upDeg] is the bearing drawn at the top: 0 for north-up, the aircraft's /// heading for heading-up. Points past the range are pinned to the rim, so /// the direction still reads. static Offset ballOffset({ required double bearingDeg, required double distanceM, required double radius, required double rangeM, double upDeg = 0, }) { final double r = radius * (distanceM / rangeM).clamp(0.0, 1.0); final double a = _rad(bearingDeg - upDeg); return Offset(r * math.sin(a), -r * math.cos(a)); } /// Where the horizon crosses the ball's vertical axis, as a fraction of the /// radius from the centre (positive = below centre). As on an artificial /// horizon, a nose-down pitch (negative in the SDK) shows more ground, so the /// horizon moves *up*; ±[fullScaleDeg] reaches the rim. static double horizonShift(double pitchDeg, {double fullScaleDeg = 45}) => (pitchDeg / fullScaleDeg).clamp(-1.0, 1.0); } /// Where the smart battery bar draws its fill and markers, as 0…1 fractions of /// its width. A null marker is one the flight controller has not reported. class BatteryBarLayout { const BatteryBarLayout({required this.fill, this.landMarker, this.homeMarker, this.lowMarker, this.criticalMarker}); factory BatteryBarLayout.from({ int? percent, int? batteryNeededToLand, int? batteryNeededToGoHome, int? lowThreshold, int? criticalThreshold, }) { double? f(int? v) => v == null || v <= 0 ? null : (v.clamp(0, 100) / 100.0); return BatteryBarLayout( fill: (percent ?? 0).clamp(0, 100) / 100.0, landMarker: f(batteryNeededToLand), homeMarker: f(batteryNeededToGoHome), lowMarker: f(lowThreshold), criticalMarker: f(criticalThreshold), ); } final double fill; /// Charge needed to land from the current height. final double? landMarker; /// Charge needed to fly home and land — GO 4's "H" marker. final double? homeMarker; final double? lowMarker; final double? criticalMarker; /// The bar's colour band: 0 = fine, 1 = only enough to go home, 2 = only enough to land. int get band { if (landMarker != null && fill <= landMarker!) return 2; if (criticalMarker != null && fill <= criticalMarker!) return 2; if (homeMarker != null && fill <= homeMarker!) return 1; if (lowMarker != null && fill <= lowMarker!) return 1; return 0; } } /// `mm:ss`, or `h:mm:ss` past the hour; `--:--` when unknown. String formatDuration(int? seconds) { if (seconds == null || seconds < 0) return '--:--'; final int h = seconds ~/ 3600, m = (seconds % 3600) ~/ 60, s = seconds % 60; final String mm = m.toString().padLeft(2, '0'), ss = s.toString().padLeft(2, '0'); return h > 0 ? '$h:$mm:$ss' : '$mm:$ss'; }