* 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
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
// ── 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;
|
||
}
|