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:
kew
2026-03-11 00:54:51 +05:30
committed by GitHub
parent 3a10da0486
commit 147b69b944
49 changed files with 8662 additions and 3927 deletions

242
src/lib/opensky-tracks.ts Normal file
View 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);
}
}