* 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
390 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|