Aether: Great Circles, Race Conditions, and Flight Detection

Today’s Aether update added flight conflict detection, great-circle route maps, live flight data cards, and a sticky overlay. Under the surface, each of these features is driven by a formula or a logical predicate. Seven of them, to be exact.

I. Great-Circle Arc — The Flight Path Curve

The FlightRouteMap widget computes a geodesic path between two airports using the spherical law of cosines for the central angle, then SLERP-style interpolation to generate intermediate points.

Given departure coordinates (ϕ1,λ1)(\phi_1, \lambda_1) and arrival coordinates (ϕ2,λ2)(\phi_2, \lambda_2) in radians, the angular distance dd is:

d=arccos(sinϕ1sinϕ2+cosϕ1cosϕ2cos(λ2λ1))d = \arccos\bigl(\sin \phi_1 \sin \phi_2 + \cos \phi_1 \cos \phi_2 \cos(\lambda_2 - \lambda_1)\bigr)

Then for each interpolation fraction f[0,1]f \in [0, 1] across N=64N = 64 segments:

a=sin((1f)d)sind,b=sin(fd)sinda = \frac{\sin\bigl((1 - f) \cdot d\bigr)}{\sin d}, \quad b = \frac{\sin(f \cdot d)}{\sin d}

The intermediate Cartesian point on the unit sphere:

x=acosϕ1cosλ1+bcosϕ2cosλ2y=acosϕ1sinλ1+bcosϕ2sinλ2z=asinϕ1+bsinϕ2\begin{aligned} x &= a \cos \phi_1 \cos \lambda_1 + b \cos \phi_2 \cos \lambda_2 \\ y &= a \cos \phi_1 \sin \lambda_1 + b \cos \phi_2 \sin \lambda_2 \\ z &= a \sin \phi_1 + b \sin \phi_2 \end{aligned}

Back to geographic coordinates:

ϕ=arctan ⁣(zx2+y2),λ=atan2(y,x)\phi = \arctan\!\left(\frac{z}{\sqrt{x^2 + y^2}}\right), \quad \lambda = \text{atan2}(y, x)

From FlightRouteMap._greatCircleArc in flight_route_map.dart:

static List<LatLng> _greatCircleArc(
  LatLng from,
  LatLng to, {
  int segments = 64,
}) {
  final lat1 = from.latitudeInRad;
  final lon1 = from.longitudeInRad;
  final lat2 = to.latitudeInRad;
  final lon2 = to.longitudeInRad;

  // Angular distance
  final d = math.acos(
    (math.sin(lat1) * math.sin(lat2) +
            math.cos(lat1) * math.cos(lat2) * math.cos(lon2 - lon1))
        .clamp(-1.0, 1.0),
  );

  if (d < 1e-10) {
    return [from, to];
  }

  final points = <LatLng>[];
  for (var i = 0; i <= segments; i++) {
    final f = i / segments;
    final a = math.sin((1 - f) * d) / math.sin(d);
    final b = math.sin(f * d) / math.sin(d);

    final x =
        a * math.cos(lat1) * math.cos(lon1) +
        b * math.cos(lat2) * math.cos(lon2);
    final y =
        a * math.cos(lat1) * math.sin(lon1) +
        b * math.cos(lat2) * math.sin(lon2);
    final z = a * math.sin(lat1) + b * math.sin(lat2);

    final lat = math.atan2(z, math.sqrt(x * x + y * y));
    final lon = math.atan2(y, x);

    points.add(LatLng(lat * 180 / math.pi, lon * 180 / math.pi));
  }

  return points;
}

The resulting 65 points are fed to Flutter Map’s PolylineLayer as a dashed line with a faint glow underneath. Short routes render nearly straight. Transoceanic routes show the characteristic curve that makes flight maps look like flight maps.

II. Map Zoom Fitting — Log-Scale Heuristic

To fit a bounding box into a fixed viewport height, the zoom level is derived from the principle that each zoom level halves the visible span on a Mercator projection:

z=log2 ⁣(360°max(Δϕ,Δλ))0.5z = \log_2\!\left(\frac{360\degree}{\max(\Delta \phi, \Delta \lambda)}\right) - 0.5

Clamped to [1.0,12.0][1.0, 12.0].

From FlightRouteMap._fitZoom:

static double _fitZoom(LatLngBounds bounds, double viewHeight) {
  final latSpan = bounds.north - bounds.south;
  final lonSpan = bounds.east - bounds.west;
  final span = math.max(latSpan, lonSpan);

  // Rough mapping: 360 degrees = zoom 0, halving span = +1 zoom
  if (span <= 0) return 10.0;
  final zoom = math.log(360.0 / span) / math.ln2;
  // Subtract a bit for padding and aspect ratio
  return (zoom - 0.5).clamp(1.0, 12.0);
}

The bounds themselves get a dynamic padding of 15% of the range (minimum 0.5 degrees):

padϕ=max(0.15(ϕmaxϕmin), 0.5°)padλ=max(0.15(λmaxλmin), 0.5°)\begin{aligned} \text{pad}_\phi &= \max(0.15 \cdot (\phi_{\max} - \phi_{\min}),\ 0.5\degree) \\ \text{pad}_\lambda &= \max(0.15 \cdot (\lambda_{\max} - \lambda_{\min}),\ 0.5\degree) \end{aligned}

III. Flight Conflict Detection — The Scheduling Guard

Before a flight can be scheduled, the system checks whether the node already has a non-past flight. Let R\mathcal{R} be the set of recent flights and A\mathcal{A} be the set of active flights. The merged flight set is:

M=R id A\mathcal{M} = \mathcal{R}\ \cup_{\text{id}}\ \mathcal{A}

(Union by flight ID, recent takes precedence on collisions.)

Define node normalization:

norm(n)=lowercase(trim(n))"!"\text{norm}(n) = \text{lowercase}\bigl(\text{trim}(n)\bigr) \setminus \texttt{"!"}

A conflict exists iff:

fM:norm(f.nodeId)=norm(myNodeId)    ¬f.isPast\exists\, f \in \mathcal{M} : \text{norm}(f.\text{nodeId}) = \text{norm}(\text{myNodeId}) \;\wedge\; \neg f.\text{isPast}

From ScheduleFlightScreen._submitFlight:

// Check if this node already has an active or upcoming flight.
// Merge both providers: aetherFlightsProvider has a 12h departure cutoff,
// aetherActiveFlightsProvider catches flights that departed >12h ago.
final nodeId = myNode.userId ?? '!${myNode.nodeNum.toRadixString(16)}';
final normalizedNodeId = nodeId.trim().toLowerCase().replaceFirst('!', '');
final recentState = ref.read(aetherFlightsProvider);
final activeState = ref.read(aetherActiveFlightsProvider);
if (recentState is AsyncLoading && activeState is AsyncLoading) {
  showWarningSnackBar(context, 'Loading flights, please try again');
  return;
}
final recentFlights = recentState.asData?.value ?? [];
final activeFlights = activeState.asData?.value ?? [];
final mergedById = <String, AetherFlight>{};
for (final f in recentFlights) {
  mergedById[f.id] = f;
}
for (final f in activeFlights) {
  mergedById.putIfAbsent(f.id, () => f);
}
final existingFlights = mergedById.values.toList();
final conflicting = existingFlights.where((f) {
  final normalizedFlightNode = f.nodeId.trim().toLowerCase().replaceFirst(
    '!',
    '',
  );
  return normalizedFlightNode == normalizedNodeId && !f.isPast;
});

IV. Self-Flight Guard — Deferred Match Predicate

The flight matcher must never fire a “Flight Detected” notification for the user’s own flight. The self-flight predicate is a disjunction of two identity checks:

isSelf(f)=(uid    f.userId=uid)        (myNodeHex    f.nodeId=myNodeHex)\text{isSelf}(f) = \bigl(\text{uid} \neq \varnothing \;\wedge\; f.\text{userId} = \text{uid}\bigr) \;\;\lor\;\; \bigl(\text{myNodeHex} \neq \varnothing \;\wedge\; f.\text{nodeId} = \text{myNodeHex}\bigr)

But on cold start, myNodeHex may be null while cached flights are already loaded. This creates a race condition. The guard:

myNodeHex=    f.userId=ε    defer(f)\text{myNodeHex} = \varnothing \;\wedge\; f.\text{userId} = \varepsilon \implies \textbf{defer}(f)

In English: if we don’t yet know our own node number AND the flight has no Firebase UID attached, we skip the match entirely and wait for BLE sync. The myNodeNumProvider listener triggers a recheck once identity arrives.

From AetherFlightMatcherNotifier._recheckMatches:

// Guard against race conditions on cold start: if myNodeNum hasn't
// arrived from BLE yet we cannot reliably detect self-flights.
// API flights carry nodeId but empty userId, so the Firebase UID
// guard above is ineffective for them. Rather than risk a spurious
// "Flight Detected" notification for the user's own flight, defer
// ALL matches until myNodeNum is available. The myNodeNumProvider
// listener added in build() will recheck as soon as it arrives.
if (myNodeHex == null && flight.userId.isEmpty) {
  AppLogging.aether(
    'Flight matcher: deferring ${flight.flightNumber} '
    '(node=${flight.nodeId}) — myNodeNum not yet available, '
    'cannot rule out self-flight',
  );
  continue;
}

V. Can-Report Predicate — Reception Report Eligibility

A user may submit a reception report only if all of the following hold:

canReport(f)=¬isSelf(f)    ¬f.isPast    (f.isInFlight    (f.isActive    ¬f.isUpcoming))    flightNodeDetected\text{canReport}(f) = \neg\text{isSelf}(f) \;\wedge\; \neg f.\text{isPast} \;\wedge\; \bigl(f.\text{isInFlight} \;\lor\; (f.\text{isActive} \;\wedge\; \neg f.\text{isUpcoming})\bigr) \;\wedge\; \text{flightNodeDetected}

The flightNodeDetected flag further gates the submit button — you must have the flight’s node visible in your mesh network.

From AetherFlightDetailScreen:

final isOwnFlight =
    (currentUser != null &&
        liveFlight.userId.isNotEmpty &&
        liveFlight.userId == currentUser.uid) ||
    (myNodeHex != null && liveFlight.nodeId == myNodeHex);

// Only allow reporting when the flight is actually airborne AND
// it's not your own flight (you can't receive your own signal).
final canReport =
    !isOwnFlight &&
    !liveFlight.isPast &&
    (liveFlight.isInFlight ||
        (liveFlight.isActive && !liveFlight.isUpcoming));

VI. Unit Conversions — Vertical Rate and Heading

Vertical rate (m/s to feet per minute):

Vfpm=Vm/s×196.85V_{\text{fpm}} = V_{\text{m/s}} \times 196.85

Displayed as “climbing” (\uparrow) when V>0.5V > 0.5 m/s, “descending” (\downarrow) when V<0.5V < -0.5 m/s, omitted in the dead band to avoid flickering between states during level flight.

Plane icon rotation (heading to canvas angle):

θcanvas=(h°90°)π180\theta_{\text{canvas}} = \frac{(h\degree - 90\degree) \cdot \pi}{180}

The icon default orientation points right (east), so 90 degrees is subtracted to align north = 0 degrees.

VII. Sticky Header Visibility — Scroll Predicate

showSticky=scrollOffset>200px    validationResult    isActive    position\text{showSticky} = \text{scrollOffset} > 200\text{px} \;\wedge\; \text{validationResult} \neq \varnothing \;\wedge\; \text{isActive} \;\wedge\; \text{position} \neq \varnothing

Animation: slide-in from Offset(0,1)\text{Offset}(0, -1) to Offset(0,0)\text{Offset}(0, 0) over 250ms with easeOutCubic, with a parallel fade [0.01.0][0.0 \to 1.0] on easeOut. The BackdropFilter applies a σ=10\sigma = 10 Gaussian blur underneath. Same animation pattern as the active authors header in the Signals feed.

Wrapping up

Three commits, one hour, each one feeding into the next. Spherical trigonometry, race-condition guards, predicate logic for authorization, and a frosted-glass sticky header to tie it all together. The math underneath makes the magic on top feel effortless.

The formulas are not complex individually. But composed together, they are the difference between “notification spam on app launch” and “the right alert for the right flight at the right time.”