Files
aeris/src/lib/opensky-tracks.ts
kew eb1103f63f 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
2026-03-23 01:25:11 +05:30

247 lines
7.9 KiB
TypeScript

import type {
FlightTrack,
OpenSkyTrackResponse,
TrackFetchResult,
TrackWaypoint,
} from "./opensky-types";
import { FETCH_TIMEOUT_MS, ICAO24_REGEX, OPENSKY_API } from "./opensky-types";
import { parseRateLimitInfo } from "./opensky-parsing";
// ── Track Waypoint Parsing ─────────────────────────────────────────────
function parseTrackWaypoint(raw: unknown): TrackWaypoint | null {
if (!Array.isArray(raw) || raw.length < 6) return null;
const time =
typeof raw[0] === "number" && Number.isFinite(raw[0]) ? raw[0] : null;
const rawLat =
typeof raw[1] === "number" && Number.isFinite(raw[1]) ? raw[1] : null;
const rawLng =
typeof raw[2] === "number" && Number.isFinite(raw[2]) ? raw[2] : null;
const latitude =
rawLat !== null && rawLat >= -90 && rawLat <= 90 ? rawLat : null;
const longitude =
rawLng !== null && rawLng >= -180 && rawLng <= 180 ? rawLng : null;
const baroAltitude =
typeof raw[3] === "number" && Number.isFinite(raw[3]) ? raw[3] : null;
const trueTrack =
typeof raw[4] === "number" && Number.isFinite(raw[4]) ? raw[4] : null;
const onGround = raw[5] === true;
if (time === null) return null;
return { time, latitude, longitude, baroAltitude, trueTrack, onGround };
}
// ── Flight Track Parsing ───────────────────────────────────────────────
function parseFlightTrack(
icao24: string,
payload: unknown,
): FlightTrack | null {
if (typeof payload !== "object" || payload === null) return null;
const data = payload as OpenSkyTrackResponse;
const startTime =
typeof data.startTime === "number" && Number.isFinite(data.startTime)
? data.startTime
: 0;
const endTime =
typeof data.endTime === "number" && Number.isFinite(data.endTime)
? data.endTime
: 0;
const callsignRaw =
typeof data.callsign === "string"
? data.callsign
: typeof data.calllsign === "string"
? data.calllsign
: null;
const callsign = callsignRaw ? callsignRaw.trim() || null : null;
const rawPath = Array.isArray(data.path) ? data.path : [];
const parsed = rawPath
.map(parseTrackWaypoint)
.filter((p): p is TrackWaypoint => p !== null)
.filter((p) => p.latitude !== null && p.longitude !== null);
// Be defensive: some responses can be out-of-order.
parsed.sort((a, b) => a.time - b.time);
// Remove consecutive duplicates (helps avoid long straight chords when data is jittery).
const path: TrackWaypoint[] = [];
let lastLng: number | null = null;
let lastLat: number | null = null;
for (const p of parsed) {
if (lastLng !== null && lastLat !== null) {
if (p.longitude === lastLng && p.latitude === lastLat) continue;
}
path.push(p);
lastLng = p.longitude;
lastLat = p.latitude;
}
if (path.length < 2) return null;
return {
icao24,
startTime,
endTime,
callsign,
path,
};
}
// ── Fetch Track ────────────────────────────────────────────────────────
/**
* Fetch a flight track (trajectory) for an aircraft.
*
* Uses the experimental OpenSky tracks endpoint. For live flights, pass time=0
* which returns the current (ongoing) track if available.
*/
export async function fetchTrackByIcao24(
icao24: string,
time: number = 0,
signal?: AbortSignal,
): Promise<TrackFetchResult> {
const normalizedIcao24 = icao24.trim().toLowerCase();
if (!ICAO24_REGEX.test(normalizedIcao24)) {
return {
track: null,
rateLimited: false,
creditsRemaining: null,
retryAfterSeconds: null,
};
}
const safeTime = Number.isFinite(time) ? Math.max(0, Math.floor(time)) : 0;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const onExternalAbort = () => {
if (!controller.signal.aborted) controller.abort();
};
signal?.addEventListener("abort", onExternalAbort, { once: true });
if (signal?.aborted) {
onExternalAbort();
}
try {
async function fetchWithTime(
t: number,
): Promise<{ result: TrackFetchResult; notFound: boolean }> {
const urlAll = `${OPENSKY_API}/tracks/all?icao24=${encodeURIComponent(normalizedIcao24)}&time=${t}`;
const urlFallback = `${OPENSKY_API}/tracks?icao24=${encodeURIComponent(normalizedIcao24)}&time=${t}`;
async function attempt(
url: string,
): Promise<{ result: TrackFetchResult; status: number }> {
const res = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
const rateLimitInfo = parseRateLimitInfo(res);
if (res.status === 429) {
return {
status: res.status,
result: {
track: null,
rateLimited: true,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: rateLimitInfo.retryAfterSeconds,
},
};
}
if (res.status === 404 || res.status === 401 || res.status === 403) {
return {
status: res.status,
result: {
track: null,
rateLimited: false,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: null,
},
};
}
if (!res.ok) {
return {
status: res.status,
result: {
track: null,
rateLimited: false,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: null,
},
};
}
const payload = (await res.json()) as unknown;
return {
status: res.status,
result: {
track: parseFlightTrack(normalizedIcao24, payload),
rateLimited: false,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: null,
},
};
}
const primary = await attempt(urlAll);
if (primary.result.track || primary.result.rateLimited) {
return { result: primary.result, notFound: false };
}
// Some OpenSky deployments/documentation use `/tracks` instead of `/tracks/all`.
// Fall back only when the primary endpoint is missing (404), not on auth failures.
if (primary.status === 404) {
const fallback = await attempt(urlFallback);
// Only treat as "not found" if both endpoints return 404.
const notFound = fallback.status === 404;
return { result: fallback.result, notFound };
}
return { result: primary.result, notFound: false };
}
const primary = await fetchWithTime(safeTime);
if (primary.result.track || primary.result.rateLimited || safeTime !== 0) {
return primary.result;
}
// Per OpenSky docs: `time` can be any time between the start and end of a known flight.
// `time=0` only returns a live track if OpenSky considers a flight ongoing. If that lookup
// fails with a not-found response, retry once with the current timestamp.
if (!primary.notFound) {
return primary.result;
}
const nowSec = Math.floor(Date.now() / 1000);
if (nowSec > 0) {
const retry = await fetchWithTime(nowSec);
return retry.result;
}
return primary.result;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Abort is expected on effect cleanup or request timeouts. Treat it as a
// normal cancellation and return an empty result.
}
return {
track: null,
rateLimited: false,
creditsRemaining: null,
retryAfterSeconds: null,
};
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onExternalAbort);
}
}