* 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
247 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
}
|