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
This commit is contained in:
242
src/lib/opensky-tracks.ts
Normal file
242
src/lib/opensky-tracks.ts
Normal file
@ -0,0 +1,242 @@
|
||||
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 latitude =
|
||||
typeof raw[1] === "number" && Number.isFinite(raw[1]) ? raw[1] : null;
|
||||
const longitude =
|
||||
typeof raw[2] === "number" && Number.isFinite(raw[2]) ? raw[2] : 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user