feat: 3D aircraft model overhaul and multi-source flight data proxy (Resolves #15) (#16)

* Refactor aircraft photo and hero banner components to reset loading state on photo change

- Updated Lightbox component to reset image loading state when navigating between photos.
- Modified HeroBanner component to reset loading state when the photo changes.

Clean up control panel search logic

- Removed unnecessary hasResults variable in SearchContent component.

Implement flight API client with fallback mechanism

- Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky).
- Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects.
- Created flight-api-types for shared types between API responses.

Refactor useFlights hook to utilize new flight API client

- Updated useFlights hook to fetch flights using the new flight API client.
- Removed credit management logic as it is no longer applicable with the new API structure.

Fix useFlightMonitors to fetch flight data by hex address

- Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24.

Update geo utility function for better readability

- Refactored splitAtAntimeridian function to improve variable naming and clarity.

Enhance OpenSky types with additional fields

- Added typeCode and registration fields to FlightState type for better integration with readsb data.

* fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing)

* fix: improve keyboard shortcuts help focus trapping

feat: add showAirspace option to MapAttribution component

fix: clear hideTimer on ScrollArea cleanup

refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors

fix: handle sessionStorage availability in useFlightTrack

refactor: increase POLL_INTERVAL_MS in useFlights for better performance

fix: optimize keyboard shortcuts dialog check

refactor: optimize useMergedTrails by caching selected flight position

feat: extend Settings type with airspace options

refactor: improve airline logo normalization functions

refactor: enhance flight API client with serialized rate limiting

refactor: optimize registration country lookup with pre-built maps

refactor: enhance logo cache management with size limits

feat: update map attribution to include airspace option

fix: validate rawState in parseStateRow function

refactor: improve utility functions with clamp implementation

* feat: add ATC lookup functionality and GPU memory monitoring

- Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code.
- Introduced `atc-types.ts` to define types and priorities for ATC feeds.
- Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports.
- Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks.

* feat: enhance flight data handling and improve API resilience

- Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures.
- Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval.
- Updated useMergedTrails to include timestamps for trail points.
- Removed smoothAnimations setting from useSettings as it is no longer needed.
- Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios.
- Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries.
- Updated flight API parsing to reject non-JSON responses from OpenSky and other providers.
- Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data.

* feat: migrate aircraft models to Cloudinary CDN and update mapping logic

* fix: adjust UI component styles and improve trail smoothing parameters

* fix: adjust base aircraft size for improved rendering

* feat: update changelog with recent enhancements and modify data source attribution

* fix: update model optimization details and remove Draco compression dependency

* feat: update changelog with recent code review fixes and fallback provider adjustments
This commit is contained in:
kew
2026-03-23 01:25:11 +05:30
committed by GitHub
parent 147b69b944
commit eb1103f63f
95 changed files with 9667 additions and 1384 deletions

View File

@ -0,0 +1,253 @@
// ── readsb Parser ────────────────────────────────────────────────────
//
// Converts raw readsb JSON (RawAircraft[]) → FlightState[].
// Handles unit conversions, edge cases, and stale-position filtering.
// Works identically for Airplanes.live and adsb.lol responses.
// ────────────────────────────────────────────────────────────────────────
import type { FlightState } from "./opensky-types";
import type { RawAircraft } from "./flight-api-types";
import { MAX_POSITION_AGE_S } from "./flight-api-types";
// ── Unit Conversion Constants ──────────────────────────────────────────
/** Feet → Meters */
const FT_TO_M = 0.3048;
/** Knots → Meters per second */
const KT_TO_MS = 0.514444;
/** Feet per minute → Meters per second */
const FTPM_TO_MS = 0.00508;
// ── Registration → Country Lookup ──────────────────────────────────────
//
// readsb doesn't provide originCountry. We derive it from the
// registration prefix. Sorted by prefix length descending so longer
// prefixes match first (e.g. "EC-" before "E").
const REG_PREFIX_TO_COUNTRY: readonly [string, string][] = [
// 3-char prefixes
["EC-", "Spain"],
["HB-", "Switzerland"],
["OE-", "Austria"],
["PH-", "Netherlands"],
["SE-", "Sweden"],
["OY-", "Denmark"],
["OH-", "Finland"],
["LN-", "Norway"],
["9V-", "Singapore"],
["9M-", "Malaysia"],
["HS-", "Thailand"],
["PK-", "Indonesia"],
["VH-", "Australia"],
["ZK-", "New Zealand"],
["PP-", "Brazil"],
["PT-", "Brazil"],
["XA-", "Mexico"],
["LV-", "Argentina"],
["A6-", "UAE"],
["A7-", "Qatar"],
["HZ-", "Saudi Arabia"],
["4X-", "Israel"],
["TC-", "Turkey"],
["SU-", "Egypt"],
["5N-", "Nigeria"],
["ZS-", "South Africa"],
["AP-", "Pakistan"],
["EI-", "Ireland"],
["OO-", "Belgium"],
["CS-", "Portugal"],
["SX-", "Greece"],
["SP-", "Poland"],
["OK-", "Czech Republic"],
["HA-", "Hungary"],
["YR-", "Romania"],
["UR-", "Ukraine"],
["RA-", "Russia"],
["VP-", "Bermuda"],
// 2-char prefixes
["C-", "Canada"],
["G-", "United Kingdom"],
["D-", "Germany"],
["F-", "France"],
["I-", "Italy"],
["B-", "China"],
// 2-char prefixes (no hyphen)
["JA", "Japan"],
["HL", "South Korea"],
["VT", "India"],
// 1-char prefix
["N", "United States"],
];
// Pre-build Maps by prefix length for O(1) lookup instead of O(42) linear scan
const REG_BY_3 = new Map<string, string>();
const REG_BY_2 = new Map<string, string>();
const REG_BY_1 = new Map<string, string>();
for (const [prefix, country] of REG_PREFIX_TO_COUNTRY) {
if (prefix.length >= 3) REG_BY_3.set(prefix, country);
else if (prefix.length === 2) REG_BY_2.set(prefix, country);
else REG_BY_1.set(prefix, country);
}
function countryFromRegistration(reg: string | undefined): string {
if (!reg) return "Unknown";
const upper = reg.toUpperCase();
return (
REG_BY_3.get(upper.slice(0, 3)) ??
REG_BY_2.get(upper.slice(0, 2)) ??
REG_BY_1.get(upper[0]) ??
"Unknown"
);
}
// ── Category Conversion ────────────────────────────────────────────────
//
// Converts readsb category string ("A0""D7") to the numeric encoding
// used by OpenSky (DO-260B spec). A-set: A0→0, A1→2(light)…A7→8(rotorcraft).
// B-set: B0→0, B1→9(glider)…B7→15(space). C-set: surface vehicles. D: reserved.
function readsbCategoryToNumber(cat: string | undefined): number | null {
if (!cat || cat.length !== 2) return null;
const set = cat.charAt(0).toUpperCase();
const idx = Number.parseInt(cat.charAt(1), 10);
if (!Number.isFinite(idx) || idx < 0 || idx > 7) return null;
switch (set) {
case "A":
return idx === 0 ? 0 : idx + 1;
case "B":
return idx === 0 ? 0 : idx + 8;
case "C":
return idx === 0 ? 0 : idx + 15;
case "D":
return 0;
default:
return null;
}
}
// ── Position Source Mapping ─────────────────────────────────────────────
/** Maps readsb `type` field to OpenSky positionSource: 0=ADS-B, 1=MLAT, 2=TIS-B */
function readsbTypeToPositionSource(type: string | undefined): number {
if (!type) return 0;
if (type === "mlat") return 1;
if (type.startsWith("tisb")) return 2;
return 0;
}
// ── Altitude Parser ────────────────────────────────────────────────────
function parseAltBaro(value: number | "ground" | undefined): {
altitude: number | null;
onGround: boolean;
} {
if (value === "ground") return { altitude: 0, onGround: true };
if (typeof value === "number" && Number.isFinite(value))
return { altitude: value * FT_TO_M, onGround: false };
return { altitude: null, onGround: false };
}
// ── ICAO Hex Validation ────────────────────────────────────────────────
const ICAO_HEX_RE = /^[0-9a-f]{6}$/i;
function isValidIcaoHex(hex: string): boolean {
// Filter out '~'-prefixed non-ICAO addresses and invalid formats
return !hex.startsWith("~") && ICAO_HEX_RE.test(hex);
}
// ── Single Aircraft Parser ─────────────────────────────────────────────
function parseRawAircraft(raw: RawAircraft): FlightState | null {
// Reject non-ICAO addresses (TIS-B, etc.)
if (!isValidIcaoHex(raw.hex)) return null;
// Require a valid position within geographic bounds
if (typeof raw.lat !== "number" || typeof raw.lon !== "number") return null;
if (!Number.isFinite(raw.lat) || !Number.isFinite(raw.lon)) return null;
if (raw.lat < -90 || raw.lat > 90 || raw.lon < -180 || raw.lon > 180)
return null;
// Filter stale positions (>60s old)
if (typeof raw.seen_pos === "number" && raw.seen_pos > MAX_POSITION_AGE_S)
return null;
const { altitude, onGround } = parseAltBaro(raw.alt_baro);
return {
icao24: raw.hex.toLowerCase(),
callsign: raw.flight?.trim() || null,
originCountry: countryFromRegistration(raw.r),
longitude: raw.lon,
latitude: raw.lat,
baroAltitude: altitude,
onGround,
velocity:
typeof raw.gs === "number" && Number.isFinite(raw.gs)
? raw.gs * KT_TO_MS
: null,
trueTrack:
typeof raw.track === "number" && Number.isFinite(raw.track)
? raw.track
: null,
verticalRate:
typeof raw.baro_rate === "number" && Number.isFinite(raw.baro_rate)
? raw.baro_rate * FTPM_TO_MS
: null,
geoAltitude:
typeof raw.alt_geom === "number" && Number.isFinite(raw.alt_geom)
? raw.alt_geom * FT_TO_M
: null,
squawk: raw.squawk ?? null,
spiFlag: raw.spi === 1,
positionSource: readsbTypeToPositionSource(raw.type),
category: readsbCategoryToNumber(raw.category),
typeCode: raw.t?.trim() || null,
registration: raw.r?.trim() || null,
};
}
// ── Batch Parser ───────────────────────────────────────────────────────
export interface ParseOptions {
/** Include aircraft on the ground. Default: false. */
includeGround?: boolean;
/** Require barometric altitude. Default: true. */
requireBaroAltitude?: boolean;
}
/**
* Parses an array of raw readsb aircraft entries into FlightState[].
* Handles unit conversions, filters stale/invalid positions, and
* converts category strings to numeric codes for backward compatibility.
*/
export function parseAircraftList(
rawList: RawAircraft[],
options?: ParseOptions,
): FlightState[] {
const includeGround = options?.includeGround ?? false;
const requireBaroAltitude = options?.requireBaroAltitude ?? true;
const results: FlightState[] = [];
for (const raw of rawList) {
if (!raw || typeof raw !== "object") continue;
const state = parseRawAircraft(raw);
if (!state) continue;
// Filter ground aircraft unless specifically requested
if (!includeGround && state.onGround) continue;
// Filter aircraft without barometric altitude if required
if (requireBaroAltitude && state.baroAltitude === null) continue;
results.push(state);
}
return results;
}