Files
PilotVault/Fly App/lib/fly/hud_geometry.dart
T
tajniak81andClaude Opus 5.5 590f2a6152 Give the Fly App a GO 4-style flight HUD
The new flight screen (lib/ui/fly/fly_screen.dart) is built from layers.
Each readout listens to its own telemetry sub-model, so a 10 Hz tick only
repaints what changed. It adds a status bar with a status chip and tip
marquee, a smart battery bar with land/RTH markers, a camera info strip,
an attitude ball, an H/D/VS/HS/VPS strip, and a minimap that swaps places
with the video when tapped. The HUD is laid out in design pixels and
scaled to phone or tablet inside the safe area.

What the HUD shows depends on the product: dual or multi battery bars,
SSD status, a thermal spot reading, a master/slave RC badge, and Wi-Fi
bars in place of RC/HD. The bridges now report the data for these: the
camera's ssd/thermal groups and the RC's master/slave mode.

The old FlightControlPage can still be opened from Settings > About >
Classic flight screen until the P3 safety flows land. The same tab also
gets a toggle for the performance overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:54:09 +02:00

112 lines
4.5 KiB
Dart

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<double> ballRanges = <double>[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';
}