* 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
499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
// ── readsb API Client ────────────────────────────────────────────────
|
|
//
|
|
// 2-tier fallback: adsb.lol proxy → OpenSky.
|
|
// Dev/override: ?provider=airplanes|adsb|opensky in the URL.
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
import type { FlightState } from "./opensky-types";
|
|
import type { ReadsbApiResponse } from "./flight-api-types";
|
|
import { MAX_RADIUS_NM, NM_PER_DEG_LAT } from "./flight-api-types";
|
|
import { parseAircraftList, type ParseOptions } from "./flight-api-parsing";
|
|
import {
|
|
bboxFromCenter,
|
|
fetchFlightsByBbox,
|
|
fetchFlightByIcao24 as openskyFetchByIcao24,
|
|
} from "./opensky-flights";
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
|
|
export type ProviderName = "airplanes" | "adsb" | "opensky" | "auto";
|
|
|
|
export interface FlightApiFetchResult {
|
|
flights: FlightState[];
|
|
rateLimited: boolean;
|
|
source?: string;
|
|
}
|
|
|
|
// ── Circuit Breaker ────────────────────────────────────────────────────
|
|
//
|
|
// Prevents hammering a dead provider. After 3 consecutive non-abort,
|
|
// non-rate-limit failures the circuit OPENS — the tier is skipped for a
|
|
// cooldown window. After the window elapses the state transitions to
|
|
// HALF-OPEN and a single probe request is allowed through:
|
|
// • probe succeeds → CLOSED (reset)
|
|
// • probe fails → OPEN (cooldown doubles, capped at 120 s)
|
|
//
|
|
// What counts as a failure:
|
|
// ✓ Timeout, HTTP 5xx, non-JSON response, network error
|
|
// ✗ AbortError (tab switch / navigation)
|
|
// ✗ 429 rate-limit (server is alive, handled separately)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
export type CircuitState = "closed" | "open" | "half-open";
|
|
|
|
interface TierCircuit {
|
|
state: CircuitState;
|
|
failures: number;
|
|
/** Timestamp after which OPEN → HALF-OPEN */
|
|
openUntil: number;
|
|
}
|
|
|
|
const CIRCUIT_FAILURE_THRESHOLD = 3;
|
|
const CIRCUIT_BASE_COOLDOWN_MS = 30_000; // 30 s
|
|
const CIRCUIT_MAX_COOLDOWN_MS = 120_000; // 2 min
|
|
|
|
const circuits = new Map<string, TierCircuit>();
|
|
|
|
function shouldSkipTier(tierId: string): boolean {
|
|
const c = circuits.get(tierId);
|
|
if (!c || c.state === "closed") return false;
|
|
if (c.state === "open" && Date.now() >= c.openUntil) {
|
|
// Cooldown expired — allow one probe
|
|
c.state = "half-open";
|
|
return false;
|
|
}
|
|
return c.state === "open";
|
|
}
|
|
|
|
function recordSuccess(tierId: string): void {
|
|
circuits.set(tierId, { state: "closed", failures: 0, openUntil: 0 });
|
|
}
|
|
|
|
function recordFailure(tierId: string): void {
|
|
const c = circuits.get(tierId) ?? {
|
|
state: "closed" as CircuitState,
|
|
failures: 0,
|
|
openUntil: 0,
|
|
};
|
|
c.failures++;
|
|
if (c.failures >= CIRCUIT_FAILURE_THRESHOLD) {
|
|
// Cooldown: 30s → 60s → 120s → 120s …
|
|
const exponent = c.failures - CIRCUIT_FAILURE_THRESHOLD;
|
|
const cooldown = Math.min(
|
|
CIRCUIT_BASE_COOLDOWN_MS * Math.pow(2, exponent),
|
|
CIRCUIT_MAX_COOLDOWN_MS,
|
|
);
|
|
c.state = "open";
|
|
c.openUntil = Date.now() + cooldown;
|
|
}
|
|
circuits.set(tierId, c);
|
|
}
|
|
|
|
/** Returns true if this error should NOT trip the circuit breaker. */
|
|
function isNonCircuitError(err: unknown): boolean {
|
|
// Abort = tab switch / navigation — not a provider failure
|
|
if (err instanceof DOMException && err.name === "AbortError") return true;
|
|
// 429 = server is alive, just rate-limiting — already handled via rateLimited flag
|
|
const msg =
|
|
err instanceof Error
|
|
? err.message.toLowerCase()
|
|
: String(err).toLowerCase();
|
|
if (msg.includes("429") || msg.includes("rate limit")) return true;
|
|
return false;
|
|
}
|
|
|
|
// ── Circuit State API (for UI consumption) ─────────────────────────────
|
|
|
|
/** Read the circuit breaker state for a specific tier. */
|
|
export function getCircuitState(tierId: string): {
|
|
state: CircuitState;
|
|
failures: number;
|
|
cooldownRemaining: number;
|
|
} {
|
|
const c = circuits.get(tierId);
|
|
if (!c || c.state === "closed")
|
|
return { state: "closed", failures: 0, cooldownRemaining: 0 };
|
|
return {
|
|
state: c.state,
|
|
failures: c.failures,
|
|
cooldownRemaining: Math.max(0, c.openUntil - Date.now()),
|
|
};
|
|
}
|
|
|
|
/** Reset all circuits (e.g. on network reconnect). */
|
|
export function resetAllCircuits(): void {
|
|
circuits.clear();
|
|
}
|
|
|
|
let _onlineListenerRegistered = false;
|
|
if (typeof window !== "undefined" && !_onlineListenerRegistered) {
|
|
_onlineListenerRegistered = true;
|
|
window.addEventListener("online", resetAllCircuits);
|
|
}
|
|
|
|
// ── Provider Override (dev testing) ────────────────────────────────────
|
|
|
|
export function getProviderOverride(): ProviderName {
|
|
if (typeof window === "undefined") return "auto";
|
|
const p = new URLSearchParams(window.location.search)
|
|
.get("provider")
|
|
?.toLowerCase();
|
|
if (p === "airplanes" || p === "adsb" || p === "opensky") return p;
|
|
return "auto";
|
|
}
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
|
|
const AIRPLANES_LIVE_BASE = "https://api.airplanes.live/v2";
|
|
const DIRECT_TIMEOUT_MS = 10_000;
|
|
const PROXY_TIMEOUT_MS = 15_000;
|
|
|
|
// Client-side rate limiter for direct airplanes.live (1 req/s + margin).
|
|
// Uses a Promise chain to serialize slot acquisition — concurrent callers
|
|
// queue up instead of both reading the same timestamp and firing together.
|
|
const DIRECT_RATE_MS = 1_100;
|
|
let lastDirectTime = 0;
|
|
let rateQueue: Promise<void> = Promise.resolve();
|
|
|
|
async function acquireDirectSlot(): Promise<void> {
|
|
const slot = rateQueue.then(async () => {
|
|
const elapsed = Date.now() - lastDirectTime;
|
|
const wait = Math.max(0, DIRECT_RATE_MS - elapsed);
|
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
lastDirectTime = Date.now();
|
|
});
|
|
rateQueue = slot;
|
|
await slot;
|
|
}
|
|
|
|
// ── Internal Helpers ───────────────────────────────────────────────────
|
|
|
|
function degreesToNm(degrees: number): number {
|
|
if (!Number.isFinite(degrees) || degrees <= 0) return 150;
|
|
const nm = Math.round(degrees * NM_PER_DEG_LAT);
|
|
return Math.min(Math.max(nm, 1), MAX_RADIUS_NM);
|
|
}
|
|
|
|
/**
|
|
* Runs `fn` with a timeout. External abort signals are propagated.
|
|
*/
|
|
async function withTimeout<T>(
|
|
fn: (signal: AbortSignal) => Promise<T>,
|
|
timeoutMs: number,
|
|
externalSignal?: AbortSignal,
|
|
): Promise<T> {
|
|
if (externalSignal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
const onAbort = () => controller.abort();
|
|
externalSignal?.addEventListener("abort", onAbort);
|
|
|
|
try {
|
|
return await fn(controller.signal);
|
|
} catch (err) {
|
|
// If the external signal fired, surface as AbortError
|
|
if (externalSignal?.aborted)
|
|
throw new DOMException("Aborted", "AbortError");
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
externalSignal?.removeEventListener("abort", onAbort);
|
|
}
|
|
}
|
|
|
|
function validateReadsb(payload: unknown): ReadsbApiResponse {
|
|
if (
|
|
!payload ||
|
|
typeof payload !== "object" ||
|
|
!Array.isArray((payload as ReadsbApiResponse).ac)
|
|
) {
|
|
throw new Error("Invalid readsb response shape");
|
|
}
|
|
return payload as ReadsbApiResponse;
|
|
}
|
|
|
|
// ── Tier 1: Direct to airplanes.live ───────────────────────────────────
|
|
//
|
|
// Avoid headers that trigger CORS preflight (Cache-Control, Pragma, etc.)
|
|
// since airplanes.live returns 405 for OPTIONS. Use cache-busting query
|
|
// param instead of cache: "no-store".
|
|
|
|
async function fetchDirectAirplanesLive(
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
): Promise<ReadsbApiResponse> {
|
|
// Serialized rate limiting — concurrent callers queue up
|
|
await acquireDirectSlot();
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
|
|
return withTimeout(
|
|
async (innerSignal) => {
|
|
const sep = path.includes("?") ? "&" : "?";
|
|
const url = `${AIRPLANES_LIVE_BASE}${path}${sep}_t=${Date.now()}`;
|
|
|
|
const res = await fetch(url, { signal: innerSignal });
|
|
if (!res.ok) throw new Error(`airplanes.live ${res.status}`);
|
|
|
|
const ct = res.headers.get("content-type") ?? "";
|
|
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
|
throw new Error("airplanes.live returned non-JSON response");
|
|
}
|
|
|
|
return validateReadsb(await res.json());
|
|
},
|
|
DIRECT_TIMEOUT_MS,
|
|
signal,
|
|
);
|
|
}
|
|
|
|
// ── Tier 2: adsb.lol via server proxy ──────────────────────────────────
|
|
|
|
async function fetchViaProxy(
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
): Promise<ReadsbApiResponse> {
|
|
return withTimeout(
|
|
async (innerSignal) => {
|
|
const url = `/api/flights?path=${encodeURIComponent(path)}`;
|
|
const res = await fetch(url, { cache: "no-store", signal: innerSignal });
|
|
|
|
if (!res.ok) throw new Error(`adsb.lol proxy ${res.status}`);
|
|
|
|
const ct = res.headers.get("content-type") ?? "";
|
|
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
|
throw new Error("adsb.lol proxy returned non-JSON response");
|
|
}
|
|
|
|
return validateReadsb(await res.json());
|
|
},
|
|
PROXY_TIMEOUT_MS,
|
|
signal,
|
|
);
|
|
}
|
|
|
|
// ── Tier 3: OpenSky direct ─────────────────────────────────────────────
|
|
|
|
async function fetchFromOpenSkyPoint(
|
|
lat: number,
|
|
lon: number,
|
|
radiusDeg: number,
|
|
signal?: AbortSignal,
|
|
): Promise<FlightState[]> {
|
|
const [lamin, lamax, lomin, lomax] = bboxFromCenter(lon, lat, radiusDeg);
|
|
const result = await fetchFlightsByBbox(lamin, lamax, lomin, lomax, signal);
|
|
if (result.rateLimited) throw new Error("OpenSky rate limited (429)");
|
|
return result.flights;
|
|
}
|
|
|
|
// ── Fallback Engine ────────────────────────────────────────────────────
|
|
|
|
interface NamedTier {
|
|
id: string;
|
|
fn: () => Promise<FlightState[]>;
|
|
}
|
|
|
|
async function runFallbackChain(
|
|
tiers: NamedTier[],
|
|
signal?: AbortSignal,
|
|
): Promise<FlightApiFetchResult> {
|
|
let lastError: Error | null = null;
|
|
let allSkipped = true;
|
|
let lastTriedId: string | undefined;
|
|
|
|
for (const { id, fn } of tiers) {
|
|
if (shouldSkipTier(id)) continue;
|
|
allSkipped = false;
|
|
lastTriedId = id;
|
|
|
|
try {
|
|
const flights = await fn();
|
|
recordSuccess(id);
|
|
return { flights, rateLimited: false, source: id };
|
|
} catch (err) {
|
|
if (signal?.aborted) throw err;
|
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
|
|
|
if (!isNonCircuitError(err)) recordFailure(id);
|
|
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
}
|
|
}
|
|
|
|
if (allSkipped) {
|
|
return { flights: [], rateLimited: false, source: "none" };
|
|
}
|
|
|
|
const msg = lastError?.message?.toLowerCase() ?? "";
|
|
if (msg.includes("429") || msg.includes("rate limit")) {
|
|
return { flights: [], rateLimited: true, source: lastTriedId };
|
|
}
|
|
|
|
throw lastError ?? new Error("All flight providers failed");
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch flights within a radius of a geographic point.
|
|
* Uses the fallback chain: adsb.lol → OpenSky.
|
|
*/
|
|
export async function fetchFlightsByPoint(
|
|
lat: number,
|
|
lon: number,
|
|
radiusDeg: number,
|
|
signal?: AbortSignal,
|
|
options?: ParseOptions,
|
|
): Promise<FlightApiFetchResult> {
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
return { flights: [], rateLimited: false };
|
|
}
|
|
|
|
const radiusNm = degreesToNm(radiusDeg);
|
|
const cLat = Math.max(-90, Math.min(90, lat));
|
|
const cLon = Math.max(-180, Math.min(180, lon));
|
|
const readsbPath = `/point/${cLat.toFixed(4)}/${cLon.toFixed(4)}/${radiusNm}`;
|
|
|
|
const override = getProviderOverride();
|
|
const tiers: NamedTier[] = [];
|
|
|
|
// Skip direct airplanes.live in the browser — CORS blocks it.
|
|
// Only attempt when explicitly overridden via ?provider=airplanes.
|
|
if (override === "airplanes") {
|
|
tiers.push({
|
|
id: "airplanes",
|
|
fn: async () => {
|
|
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
|
return parseAircraftList(resp.ac, options);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (override === "auto" || override === "adsb") {
|
|
tiers.push({
|
|
id: "adsb",
|
|
fn: async () => {
|
|
const resp = await fetchViaProxy(readsbPath, signal);
|
|
return parseAircraftList(resp.ac, options);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (override === "auto" || override === "opensky") {
|
|
tiers.push({
|
|
id: "opensky",
|
|
fn: () => fetchFromOpenSkyPoint(cLat, cLon, radiusDeg, signal),
|
|
});
|
|
}
|
|
|
|
return runFallbackChain(tiers, signal);
|
|
}
|
|
|
|
/**
|
|
* Fetch a single aircraft by ICAO24 hex address.
|
|
* Uses the fallback chain: adsb.lol → OpenSky.
|
|
*/
|
|
export async function fetchFlightByHex(
|
|
icao24: string,
|
|
signal?: AbortSignal,
|
|
): Promise<{ flight: FlightState | null }> {
|
|
const normalized = icao24.trim().toLowerCase();
|
|
if (!/^[0-9a-f]{6}$/i.test(normalized)) {
|
|
return { flight: null };
|
|
}
|
|
|
|
const parseOpts: ParseOptions = {
|
|
includeGround: true,
|
|
requireBaroAltitude: false,
|
|
};
|
|
const readsbPath = `/hex/${encodeURIComponent(normalized)}`;
|
|
const override = getProviderOverride();
|
|
const tiers: NamedTier[] = [];
|
|
|
|
if (override === "airplanes") {
|
|
tiers.push({
|
|
id: "airplanes",
|
|
fn: async () => {
|
|
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
|
return parseAircraftList(resp.ac, parseOpts);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (override === "auto" || override === "adsb") {
|
|
tiers.push({
|
|
id: "adsb",
|
|
fn: async () => {
|
|
const resp = await fetchViaProxy(readsbPath, signal);
|
|
return parseAircraftList(resp.ac, parseOpts);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (override === "auto" || override === "opensky") {
|
|
tiers.push({
|
|
id: "opensky",
|
|
fn: async () => {
|
|
const result = await openskyFetchByIcao24(normalized, signal);
|
|
return result.flight ? [result.flight] : [];
|
|
},
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await runFallbackChain(tiers, signal);
|
|
return { flight: result.flights[0] ?? null };
|
|
} catch {
|
|
return { flight: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch flights matching a callsign.
|
|
* Uses: adsb.lol only (OpenSky callsign search costs 4 credits).
|
|
*/
|
|
export async function fetchFlightByCallsign(
|
|
callsign: string,
|
|
signal?: AbortSignal,
|
|
): Promise<{ flight: FlightState | null }> {
|
|
const normalized = callsign.trim().toUpperCase();
|
|
if (!normalized) return { flight: null };
|
|
|
|
const parseOpts: ParseOptions = {
|
|
includeGround: true,
|
|
requireBaroAltitude: false,
|
|
};
|
|
const readsbPath = `/callsign/${encodeURIComponent(normalized)}`;
|
|
const override = getProviderOverride();
|
|
const tiers: NamedTier[] = [];
|
|
|
|
if (override === "airplanes") {
|
|
tiers.push({
|
|
id: "airplanes",
|
|
fn: async () => {
|
|
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
|
return parseAircraftList(resp.ac, parseOpts);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (override === "auto" || override === "adsb") {
|
|
tiers.push({
|
|
id: "adsb",
|
|
fn: async () => {
|
|
const resp = await fetchViaProxy(readsbPath, signal);
|
|
return parseAircraftList(resp.ac, parseOpts);
|
|
},
|
|
});
|
|
}
|
|
|
|
// No OpenSky tier: callsign search queries all aircraft (4-credit global fetch)
|
|
|
|
try {
|
|
const result = await runFallbackChain(tiers, signal);
|
|
return { flight: result.flights[0] ?? null };
|
|
} catch {
|
|
return { flight: null };
|
|
}
|
|
}
|