Files
aeris/src/lib/opensky-flights.ts
kew 147b69b944 feat(map): enhance globe projection handling and improve altitude color representation (#14)
* feat(map): enhance globe projection handling and improve altitude color representation

- Implemented elevation-aware pixel projection for globe mode in `projectLngLatElevationPixelDelta`.
- Refactored north-up animation in `CameraController` to use `setBearing` for smoother transitions.
- Added native GeoJSON support for globe zoom in `FlightLayers`, including dynamic opacity adjustments based on zoom levels.
- Introduced globe-specific pitch and projection settings in `Map` component, ensuring consistent rendering.
- Enhanced UI control panel with a visual separator for better organization.
- Minor formatting adjustments in `altitudeToColor` function for improved readability.

* feat(map): refactor elevation-aware projection handling for improved accuracy

* feat(map): add dark terrain profile support and enhance map styling

* feat: implement trail stitching for merging historical and live flight data

- Added a new module `trail-stitching.ts` to handle the merging of sparse historical track data with high-frequency live trail data.
- Introduced constants for thresholds and parameters to improve code readability and maintainability.
- Implemented a main function `stitchHistoricalTrail` that processes flight tracks, applies smoothing, and merges live tail data.
- Included utility functions for spherical interpolation and cubic easing for altitude transitions.
- Ensured the final path is cleaned of spikes and sharp corners for a smoother representation.

* feat: add centripetal Catmull-Rom spline interpolation for 3D flight trails

- Implemented `catmullRomSpline3D` function to interpolate waypoints into a smooth 3D path.
- Added helper functions for segment density calculation, safe linear interpolation, and endpoint reflection.
- Included support for variable tension based on heading changes to enhance smoothness.
- Introduced utility functions for linear interpolation between elevated points.

* feat(map): enhance layer visibility handling for flight and selection layers

* feat: enhance control panel with new tabs and settings

- Added "Changelog" and "About" tabs to the control panel.
- Introduced new icons for the added tabs using lucide-react.
- Updated the styling of the control panel buttons and dialog.
- Improved accessibility with aria-labels for buttons.

feat: integrate hero banner in flight card

- Added a HeroBanner component to display aircraft photos in the FlightCard.
- Implemented loading and error states for the photo display.
- Enhanced the layout and styling of the FlightCard for better user experience.

fix: update keyboard shortcuts for search functionality

- Added shortcut "⌘K" to open search from anywhere in the application.
- Adjusted keyboard shortcut handling to prevent conflicts with input fields.

fix: optimize flight tracking cache management

- Introduced a maximum cache size for flight tracking to prevent memory growth.
- Implemented a cache eviction strategy for stale entries.

feat: add great-circle utilities for geographical calculations

- Implemented functions for calculating haversine distance, great-circle interpolation, and densifying paths.
- Added functionality to handle antimeridian crossings in geographical paths.

refactor: streamline map styles and terrain handling

- Consolidated terrain DEM source for both terrain mesh and hillshade.
- Adjusted hillshade layer properties for better performance and visual fidelity.

fix: improve bounding box calculations for flight queries

- Enhanced longitude calculations to account for converging meridians at higher latitudes.
- Ensured bounding box calculations are accurate across different latitudes.

* feat(map): refine globe mode functionality and update trail settings
2026-03-11 00:54:51 +05:30

390 lines
12 KiB
TypeScript

import type {
CallsignLookupResult,
FetchResult,
FlightState,
OpenSkyResponse,
} from "./opensky-types";
import {
CALLSIGN_CACHE_MAX_ENTRIES,
CALLSIGN_CACHE_TTL_MS,
FETCH_TIMEOUT_MS,
ICAO24_REGEX,
MAX_1_CREDIT_RADIUS_DEG,
OPENSKY_API,
SEGMENT_DELAY_MS,
} from "./opensky-types";
import {
normalizeCallsign,
normalizeBounds,
parseRateLimitInfo,
parseStates,
} from "./opensky-parsing";
// ── Bounding Box Flights ───────────────────────────────────────────────
export async function fetchFlightsByBbox(
lamin: number,
lamax: number,
lomin: number,
lomax: number,
signal?: AbortSignal,
): Promise<FetchResult> {
const [la0, la1] = normalizeBounds(lamin, lamax, -90, 90);
const [lo0, lo1] = normalizeBounds(lomin, lomax, -180, 180);
const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}&extended=1`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const onExternalAbort = () => controller.abort();
signal?.addEventListener("abort", onExternalAbort);
try {
const res = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
const rateLimitInfo = parseRateLimitInfo(res);
if (res.status === 429) {
return {
flights: [],
rateLimited: true,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: rateLimitInfo.retryAfterSeconds,
};
}
if (!res.ok) {
return {
flights: [],
rateLimited: false,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: null,
};
}
const payload = (await res.json()) as unknown;
const data =
typeof payload === "object" && payload !== null
? (payload as OpenSkyResponse)
: { time: 0, states: null };
return {
flights: parseStates(data),
rateLimited: false,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: null,
};
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
if (signal?.aborted) throw err;
throw new Error("OpenSky request timed out");
}
throw err;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onExternalAbort);
}
}
// ── Bbox Helper ────────────────────────────────────────────────────────
export function bboxFromCenter(
lng: number,
lat: number,
radiusDeg: number,
): [lamin: number, lamax: number, lomin: number, lomax: number] {
// If callers pass a bogus radius, fall back to a safe 1-credit value.
const safeRadiusRaw =
Number.isFinite(radiusDeg) && radiusDeg > 0
? radiusDeg
: MAX_1_CREDIT_RADIUS_DEG;
const safeRadius = Math.min(safeRadiusRaw, MAX_1_CREDIT_RADIUS_DEG);
// Compensate longitude extent for converging meridians at higher latitudes.
// At the equator cos(0)=1 so lngRadius equals safeRadius (no change).
// At 60°N cos(60°)=0.5 so lngRadius doubles to cover the same ground distance.
// Clamp near poles to avoid division by near-zero.
const cosLat = Math.cos((Math.abs(lat) * Math.PI) / 180);
const lngRadius = Math.min(180, safeRadius / Math.max(cosLat, 0.01));
return [lat - safeRadius, lat + safeRadius, lng - lngRadius, lng + lngRadius];
}
// ── Single Aircraft by ICAO24 ──────────────────────────────────────────
/**
* Fetch a single aircraft's state by its ICAO24 address (global lookup).
* Costs 4 API credits (no bbox = full globe) but returns at most one result.
* Returns the flight if found, or null.
*/
export async function fetchFlightByIcao24(
icao24: string,
signal?: AbortSignal,
): Promise<{ flight: FlightState | null; creditsRemaining: number | null }> {
const normalizedIcao24 = icao24.trim().toLowerCase();
if (!ICAO24_REGEX.test(normalizedIcao24)) {
return { flight: null, creditsRemaining: null };
}
const url = `${OPENSKY_API}/states/all?icao24=${encodeURIComponent(normalizedIcao24)}&extended=1`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const onExternalAbort = () => controller.abort();
signal?.addEventListener("abort", onExternalAbort);
try {
const res = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
const rateLimitInfo = parseRateLimitInfo(res);
if (res.status === 429 || !res.ok) {
return { flight: null, creditsRemaining: rateLimitInfo.creditsRemaining };
}
const payload = (await res.json()) as unknown;
const data =
typeof payload === "object" && payload !== null
? (payload as OpenSkyResponse)
: { time: 0, states: null };
const flights = parseStates(data, {
includeGround: true,
requireBaroAltitude: false,
});
return {
flight: flights.find((f) => f.icao24 === normalizedIcao24) ?? null,
creditsRemaining: rateLimitInfo.creditsRemaining,
};
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
if (signal?.aborted) throw err;
}
return { flight: null, creditsRemaining: null };
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onExternalAbort);
}
}
// ── Callsign Search ────────────────────────────────────────────────────
const callsignLookupCache = new Map<
string,
{ timestamp: number; result: CallsignLookupResult }
>();
// In-flight promise dedup: prevents concurrent 4-credit global fetches
// for the same normalized callsign query.
const callsignInFlight = new Map<string, Promise<CallsignLookupResult>>();
export async function fetchFlightByCallsign(
callsign: string,
signal?: AbortSignal,
): Promise<CallsignLookupResult> {
const normalizedQuery = normalizeCallsign(callsign);
if (!normalizedQuery) {
return {
flight: null,
creditsRemaining: null,
rateLimited: false,
retryAfterSeconds: null,
};
}
const cached = callsignLookupCache.get(normalizedQuery);
if (cached && Date.now() - cached.timestamp <= CALLSIGN_CACHE_TTL_MS) {
return cached.result;
}
// If there's already an in-flight request for this query, piggyback on it
const existing = callsignInFlight.get(normalizedQuery);
if (existing) return existing;
const promise = fetchFlightByCallsignImpl(normalizedQuery, signal);
callsignInFlight.set(normalizedQuery, promise);
try {
return await promise;
} finally {
callsignInFlight.delete(normalizedQuery);
}
}
async function fetchFlightByCallsignImpl(
normalizedQuery: string,
signal?: AbortSignal,
): Promise<CallsignLookupResult> {
const url = `${OPENSKY_API}/states/all?extended=1`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const onExternalAbort = () => controller.abort();
signal?.addEventListener("abort", onExternalAbort);
try {
const res = await fetch(url, {
cache: "no-store",
signal: controller.signal,
});
const rateLimitInfo = parseRateLimitInfo(res);
if (res.status === 429) {
return {
flight: null,
creditsRemaining: rateLimitInfo.creditsRemaining,
rateLimited: true,
retryAfterSeconds: rateLimitInfo.retryAfterSeconds,
};
}
if (!res.ok) {
return {
flight: null,
creditsRemaining: rateLimitInfo.creditsRemaining,
rateLimited: false,
retryAfterSeconds: null,
};
}
const payload = (await res.json()) as unknown;
const data =
typeof payload === "object" && payload !== null
? (payload as OpenSkyResponse)
: { time: 0, states: null };
const flights = parseStates(data, {
includeGround: true,
requireBaroAltitude: false,
});
const exact = flights.find(
(f) => normalizeCallsign(f.callsign) === normalizedQuery,
);
const startsWith =
exact ??
flights.find((f) =>
normalizeCallsign(f.callsign).startsWith(normalizedQuery),
);
const contains =
startsWith ??
flights.find((f) =>
normalizeCallsign(f.callsign).includes(normalizedQuery),
);
const result: CallsignLookupResult = {
flight: contains ?? null,
creditsRemaining: rateLimitInfo.creditsRemaining,
rateLimited: false,
retryAfterSeconds: null,
};
callsignLookupCache.set(normalizedQuery, {
timestamp: Date.now(),
result,
});
if (callsignLookupCache.size > CALLSIGN_CACHE_MAX_ENTRIES) {
const oldestKey = callsignLookupCache.keys().next().value as
| string
| undefined;
if (oldestKey) callsignLookupCache.delete(oldestKey);
}
return result;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
if (signal?.aborted) throw err;
}
return {
flight: null,
creditsRemaining: null,
rateLimited: false,
retryAfterSeconds: null,
};
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onExternalAbort);
}
}
// ── Route Corridor Fetch ───────────────────────────────────────────────
/**
* Fetch flights across multiple bounding-box segments (for route corridors).
* Segments are fetched sequentially with a small delay to avoid burst rate limits.
* Results are merged and deduplicated by icao24.
*
* If a 429 is received mid-sequence, partial results collected so far are returned
* with `rateLimited: true`.
*/
export async function fetchFlightsByRoute(
segments: { lamin: number; lamax: number; lomin: number; lomax: number }[],
signal?: AbortSignal,
): Promise<FetchResult> {
if (segments.length === 0) {
return {
flights: [],
rateLimited: false,
creditsRemaining: null,
retryAfterSeconds: null,
};
}
const seen = new Map<string, FlightState>();
let rateLimited = false;
let lowestCredits: number | null = null;
let retryAfterSeconds: number | null = null;
for (let i = 0; i < segments.length; i++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const seg = segments[i];
const result = await fetchFlightsByBbox(
seg.lamin,
seg.lamax,
seg.lomin,
seg.lomax,
signal,
);
for (const f of result.flights) {
if (!seen.has(f.icao24)) {
seen.set(f.icao24, f);
}
}
if (result.creditsRemaining !== null) {
lowestCredits =
lowestCredits === null
? result.creditsRemaining
: Math.min(lowestCredits, result.creditsRemaining);
}
if (result.rateLimited) {
rateLimited = true;
retryAfterSeconds = result.retryAfterSeconds;
break;
}
if (i < segments.length - 1) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, SEGMENT_DELAY_MS);
const onAbort = () => {
clearTimeout(timer);
resolve();
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
}
return {
flights: Array.from(seen.values()),
rateLimited,
creditsRemaining: lowestCredits,
retryAfterSeconds,
};
}