feat: implement full flight history tracking and enhance trail rendering (#11)
* feat: implement full flight history tracking and enhance trail rendering * feat: enhance flight tracking logic and improve path handling * feat: implement airline logo caching and error handling in flight components * feat: enhance flight tracking logic to improve waypoint handling and connection logic * feat: refactor longitude handling and improve flight tracking logic * feat: improve longitude handling and enhance airline logo failure tracking
This commit is contained in:
@ -26,6 +26,7 @@ import { SettingsProvider, useSettings } from "@/hooks/use-settings";
|
|||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useFlights } from "@/hooks/use-flights";
|
import { useFlights } from "@/hooks/use-flights";
|
||||||
import { useTrailHistory } from "@/hooks/use-trail-history";
|
import { useTrailHistory } from "@/hooks/use-trail-history";
|
||||||
|
import { useFlightTrack } from "@/hooks/use-flight-track";
|
||||||
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
||||||
import { CITIES, type City } from "@/lib/cities";
|
import { CITIES, type City } from "@/lib/cities";
|
||||||
import { AIRPORTS, findByIata, airportToCity } from "@/lib/airports";
|
import { AIRPORTS, findByIata, airportToCity } from "@/lib/airports";
|
||||||
@ -34,6 +35,7 @@ import {
|
|||||||
fetchFlightByCallsign,
|
fetchFlightByCallsign,
|
||||||
type FlightState,
|
type FlightState,
|
||||||
} from "@/lib/opensky";
|
} from "@/lib/opensky";
|
||||||
|
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
|
||||||
import { formatCallsign } from "@/lib/flight-utils";
|
import { formatCallsign } from "@/lib/flight-utils";
|
||||||
import type { PickingInfo } from "@deck.gl/core";
|
import type { PickingInfo } from "@deck.gl/core";
|
||||||
import { Github, Star, Keyboard } from "lucide-react";
|
import { Github, Star, Keyboard } from "lucide-react";
|
||||||
@ -270,6 +272,267 @@ function FlightTrackerInner() {
|
|||||||
const displayFlights = flights;
|
const displayFlights = flights;
|
||||||
const displayTrails = useTrailHistory(displayFlights);
|
const displayTrails = useTrailHistory(displayFlights);
|
||||||
|
|
||||||
|
// Fetch /tracks only for explicit click-selection (never FPV).
|
||||||
|
const selectedFlightForTrack = useMemo(() => {
|
||||||
|
if (!selectedIcao24) return null;
|
||||||
|
return displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
|
||||||
|
}, [selectedIcao24, displayFlights]);
|
||||||
|
|
||||||
|
const shouldFetchSelectedTrack =
|
||||||
|
!!selectedIcao24 &&
|
||||||
|
!fpvIcao24 &&
|
||||||
|
!(selectedFlightForTrack?.onGround ?? false);
|
||||||
|
|
||||||
|
const { track: selectedTrack, fetchedAtMs: selectedTrackFetchedAtMs } =
|
||||||
|
useFlightTrack(selectedIcao24, {
|
||||||
|
enabled: shouldFetchSelectedTrack,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedTrails = useMemo(() => {
|
||||||
|
if (!selectedIcao24 || !selectedTrack) return displayTrails;
|
||||||
|
|
||||||
|
const flight =
|
||||||
|
displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
|
||||||
|
|
||||||
|
const livePos: [number, number] | null =
|
||||||
|
flight && flight.longitude != null && flight.latitude != null
|
||||||
|
? [flight.longitude, flight.latitude]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const trackPositions: [number, number][] = [];
|
||||||
|
const trackAltitudes: Array<number | null> = [];
|
||||||
|
|
||||||
|
for (const p of selectedTrack.path) {
|
||||||
|
if (p.longitude == null || p.latitude == null) continue;
|
||||||
|
trackPositions.push([p.longitude, p.latitude]);
|
||||||
|
trackAltitudes.push(p.baroAltitude ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap longitudes to avoid dateline/world-wrap glitches.
|
||||||
|
if (trackPositions.length >= 2) {
|
||||||
|
const unwrapped = unwrapLngPath(trackPositions);
|
||||||
|
trackPositions.splice(0, trackPositions.length, ...unwrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
const livePosAdjusted: [number, number] | null =
|
||||||
|
livePos && trackPositions.length > 0
|
||||||
|
? [
|
||||||
|
snapLngToReference(
|
||||||
|
livePos[0],
|
||||||
|
trackPositions[trackPositions.length - 1][0],
|
||||||
|
),
|
||||||
|
livePos[1],
|
||||||
|
]
|
||||||
|
: livePos;
|
||||||
|
|
||||||
|
const lastWaypointTime =
|
||||||
|
selectedTrack.path[selectedTrack.path.length - 1]?.time;
|
||||||
|
const nowSec =
|
||||||
|
selectedTrackFetchedAtMs > 0
|
||||||
|
? Math.floor(selectedTrackFetchedAtMs / 1000)
|
||||||
|
: 0;
|
||||||
|
const lastWaypointAgeSec =
|
||||||
|
typeof lastWaypointTime === "number" && Number.isFinite(lastWaypointTime)
|
||||||
|
? Math.max(0, nowSec - lastWaypointTime)
|
||||||
|
: 0;
|
||||||
|
const speedMps =
|
||||||
|
flight && Number.isFinite(flight.velocity) && flight.velocity! > 30
|
||||||
|
? Math.max(0, flight.velocity!)
|
||||||
|
: 140;
|
||||||
|
const expectedDeg = (speedMps * lastWaypointAgeSec) / 111_320;
|
||||||
|
|
||||||
|
// Guard against wrong tracks (tolerate sparse/laggy waypoints).
|
||||||
|
if (livePosAdjusted && trackPositions.length >= 2) {
|
||||||
|
const searchWindow = 70;
|
||||||
|
const start = Math.max(0, trackPositions.length - searchWindow);
|
||||||
|
let bestDistSq = Number.POSITIVE_INFINITY;
|
||||||
|
for (let i = start; i < trackPositions.length; i++) {
|
||||||
|
const p = trackPositions[i];
|
||||||
|
const dx = p[0] - livePosAdjusted[0];
|
||||||
|
const dy = p[1] - livePosAdjusted[1];
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < bestDistSq) bestDistSq = d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracks can be sparse; scale tolerance by speed and waypoint age.
|
||||||
|
const lowAltitude =
|
||||||
|
flight && Number.isFinite(flight.baroAltitude)
|
||||||
|
? flight.baroAltitude! < 6_000
|
||||||
|
: false;
|
||||||
|
const maxAllowedDeg = Math.min(
|
||||||
|
lowAltitude ? 2.8 : 6,
|
||||||
|
Math.max(lowAltitude ? 0.75 : 0.9, expectedDeg * 1.35 + 0.22),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bestDistSq > maxAllowedDeg * maxAllowedDeg) {
|
||||||
|
return displayTrails;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the high-frequency live tail for recent turns.
|
||||||
|
const existingTrail =
|
||||||
|
displayTrails.find((t) => t.icao24 === selectedIcao24) ?? null;
|
||||||
|
if (existingTrail && existingTrail.path.length >= 2) {
|
||||||
|
const tailCount = 18;
|
||||||
|
const start = Math.max(0, existingTrail.path.length - tailCount);
|
||||||
|
const rawTailPath = existingTrail.path.slice(start);
|
||||||
|
const tailAlt = existingTrail.altitudes.slice(start);
|
||||||
|
|
||||||
|
// Unwrap tail points to be continuous with the historical track.
|
||||||
|
const tailPath: [number, number][] = [];
|
||||||
|
let refLng =
|
||||||
|
trackPositions.length > 0
|
||||||
|
? trackPositions[trackPositions.length - 1][0]
|
||||||
|
: rawTailPath[0][0];
|
||||||
|
for (const [lng, lat] of rawTailPath) {
|
||||||
|
const nextLng = snapLngToReference(lng, refLng);
|
||||||
|
tailPath.push([nextLng, lat]);
|
||||||
|
refLng = nextLng;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge where the two data sources overlap near the end.
|
||||||
|
const MERGE_SNAP_DEG = 0.06;
|
||||||
|
const CONNECT_BRIDGE_DEG = 0.07;
|
||||||
|
const MAX_CONNECT_GAP_DEG =
|
||||||
|
flight &&
|
||||||
|
Number.isFinite(flight.baroAltitude) &&
|
||||||
|
flight.baroAltitude! < 6_000
|
||||||
|
? 1.25
|
||||||
|
: 3.5;
|
||||||
|
|
||||||
|
const firstTail = tailPath[0];
|
||||||
|
const searchWindow = 70;
|
||||||
|
const searchStart = Math.max(0, trackPositions.length - searchWindow);
|
||||||
|
let bestIndex = -1;
|
||||||
|
let bestDistSq = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let i = searchStart; i < trackPositions.length; i++) {
|
||||||
|
const p = trackPositions[i];
|
||||||
|
const dx = p[0] - firstTail[0];
|
||||||
|
const dy = p[1] - firstTail[1];
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < bestDistSq) {
|
||||||
|
bestDistSq = d2;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestIndex >= 0 && bestDistSq <= MERGE_SNAP_DEG * MERGE_SNAP_DEG) {
|
||||||
|
// Snap to overlap, then append the live tail.
|
||||||
|
trackPositions.splice(bestIndex + 1);
|
||||||
|
trackAltitudes.splice(bestIndex + 1);
|
||||||
|
|
||||||
|
const join = trackPositions[trackPositions.length - 1];
|
||||||
|
if (join) {
|
||||||
|
tailPath[0] = join;
|
||||||
|
const joinAlt = trackAltitudes[trackAltitudes.length - 1] ?? null;
|
||||||
|
tailAlt[0] = joinAlt ?? tailAlt[0] ?? null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No overlap: disconnect stale history or insert a short bridge when close.
|
||||||
|
const last = trackPositions[trackPositions.length - 1];
|
||||||
|
const lastAlt = trackAltitudes[trackAltitudes.length - 1] ?? null;
|
||||||
|
if (last) {
|
||||||
|
const dx = last[0] - firstTail[0];
|
||||||
|
const dy = last[1] - firstTail[1];
|
||||||
|
const gap = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const shouldDisconnect =
|
||||||
|
gap > 0.25 ||
|
||||||
|
(lastWaypointAgeSec > 900 && gap > 0.06) ||
|
||||||
|
(lastWaypointAgeSec > 300 && gap > 0.1);
|
||||||
|
|
||||||
|
if (shouldDisconnect) {
|
||||||
|
trackPositions.splice(0, trackPositions.length, ...tailPath);
|
||||||
|
trackAltitudes.splice(0, trackAltitudes.length, ...tailAlt);
|
||||||
|
tailPath.length = 0;
|
||||||
|
tailAlt.length = 0;
|
||||||
|
} else {
|
||||||
|
if (gap > MAX_CONNECT_GAP_DEG) {
|
||||||
|
tailPath.length = 0;
|
||||||
|
} else if (gap > CONNECT_BRIDGE_DEG) {
|
||||||
|
const steps = Math.max(6, Math.min(24, Math.ceil(gap / 0.15)));
|
||||||
|
const firstTailAlt = tailAlt[0] ?? null;
|
||||||
|
for (let s = 1; s < steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
trackPositions.push([
|
||||||
|
last[0] + (firstTail[0] - last[0]) * t,
|
||||||
|
last[1] + (firstTail[1] - last[1]) * t,
|
||||||
|
]);
|
||||||
|
if (lastAlt == null && firstTailAlt == null) {
|
||||||
|
trackAltitudes.push(null);
|
||||||
|
} else {
|
||||||
|
const a0 = lastAlt ?? firstTailAlt ?? 0;
|
||||||
|
const a1 = firstTailAlt ?? lastAlt ?? a0;
|
||||||
|
trackAltitudes.push(a0 + (a1 - a0) * t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tailPath[0] = last;
|
||||||
|
tailAlt[0] = lastAlt ?? tailAlt[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append tail points, skipping consecutive duplicates.
|
||||||
|
for (let i = 0; i < tailPath.length; i++) {
|
||||||
|
const pos = tailPath[i];
|
||||||
|
const alt = tailAlt[i] ?? null;
|
||||||
|
const last = trackPositions[trackPositions.length - 1];
|
||||||
|
if (last && last[0] === pos[0] && last[1] === pos[1]) continue;
|
||||||
|
trackPositions.push(pos);
|
||||||
|
trackAltitudes.push(alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the trail reaches the aircraft.
|
||||||
|
if (livePosAdjusted) {
|
||||||
|
const last = trackPositions[trackPositions.length - 1];
|
||||||
|
if (
|
||||||
|
!last ||
|
||||||
|
last[0] !== livePosAdjusted[0] ||
|
||||||
|
last[1] !== livePosAdjusted[1]
|
||||||
|
) {
|
||||||
|
trackPositions.push(livePosAdjusted);
|
||||||
|
trackAltitudes.push(flight?.baroAltitude ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackPositions.length < 2) return displayTrails;
|
||||||
|
|
||||||
|
const out = displayTrails.map((t) => {
|
||||||
|
if (t.icao24 !== selectedIcao24) return t;
|
||||||
|
const baroAltitude =
|
||||||
|
trackAltitudes[trackAltitudes.length - 1] ?? t.baroAltitude ?? null;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
path: trackPositions,
|
||||||
|
altitudes: trackAltitudes,
|
||||||
|
baroAltitude,
|
||||||
|
fullHistory: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the selected aircraft didn't have an in-memory trail yet, add one.
|
||||||
|
if (!out.some((t) => t.icao24 === selectedIcao24)) {
|
||||||
|
out.push({
|
||||||
|
icao24: selectedIcao24,
|
||||||
|
path: trackPositions,
|
||||||
|
altitudes: trackAltitudes,
|
||||||
|
baroAltitude: trackAltitudes[trackAltitudes.length - 1] ?? null,
|
||||||
|
fullHistory: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}, [
|
||||||
|
selectedIcao24,
|
||||||
|
selectedTrack,
|
||||||
|
selectedTrackFetchedAtMs,
|
||||||
|
displayTrails,
|
||||||
|
displayFlights,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedFlight = useMemo(() => {
|
const selectedFlight = useMemo(() => {
|
||||||
if (!selectedIcao24) return null;
|
if (!selectedIcao24) return null;
|
||||||
return (
|
return (
|
||||||
@ -428,7 +691,7 @@ function FlightTrackerInner() {
|
|||||||
missingSinceRef.current = now;
|
missingSinceRef.current = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (now - missingSinceRef.current >= 30_000) {
|
if (now - missingSinceRef.current >= 60_000) {
|
||||||
const timer = setTimeout(() => setSelectedIcao24(null), 0);
|
const timer = setTimeout(() => setSelectedIcao24(null), 0);
|
||||||
missingSinceRef.current = null;
|
missingSinceRef.current = null;
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@ -636,7 +899,7 @@ function FlightTrackerInner() {
|
|||||||
/>
|
/>
|
||||||
<FlightLayers
|
<FlightLayers
|
||||||
flights={displayFlights}
|
flights={displayFlights}
|
||||||
trails={displayTrails}
|
trails={mergedTrails}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
selectedIcao24={fpvIcao24 ?? selectedIcao24}
|
selectedIcao24={fpvIcao24 ?? selectedIcao24}
|
||||||
showTrails={settings.showTrails}
|
showTrails={settings.showTrails}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { ScenegraphLayer } from "@deck.gl/mesh-layers";
|
|||||||
import { useMap } from "./map";
|
import { useMap } from "./map";
|
||||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||||
import type { FlightState } from "@/lib/opensky";
|
import type { FlightState } from "@/lib/opensky";
|
||||||
|
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
|
||||||
import { type TrailEntry } from "@/hooks/use-trail-history";
|
import { type TrailEntry } from "@/hooks/use-trail-history";
|
||||||
import type { PickingInfo } from "@deck.gl/core";
|
import type { PickingInfo } from "@deck.gl/core";
|
||||||
|
|
||||||
@ -241,6 +242,37 @@ function horizontalDistanceMeters(a: Snapshot, b: Snapshot): number {
|
|||||||
return horizontalDistanceFromLngLat(a.lng, a.lat, b.lng, b.lat);
|
return horizontalDistanceFromLngLat(a.lng, a.lat, b.lng, b.lat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimAfterLargeJump(
|
||||||
|
path: [number, number][],
|
||||||
|
altitudes: Array<number | null>,
|
||||||
|
maxJumpDeg: number,
|
||||||
|
): { path: [number, number][]; altitudes: Array<number | null> } {
|
||||||
|
if (path.length < 2) return { path, altitudes };
|
||||||
|
|
||||||
|
const maxJumpSq = maxJumpDeg * maxJumpDeg;
|
||||||
|
let start = 0;
|
||||||
|
for (let i = path.length - 2; i >= 0; i--) {
|
||||||
|
const a = path[i];
|
||||||
|
const b = path[i + 1];
|
||||||
|
const dx = b[0] - a[0];
|
||||||
|
const dy = b[1] - a[1];
|
||||||
|
if (dx * dx + dy * dy > maxJumpSq) {
|
||||||
|
start = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 0) {
|
||||||
|
start = Math.min(start, path.length - 2);
|
||||||
|
return {
|
||||||
|
path: path.slice(start),
|
||||||
|
altitudes: altitudes.slice(start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path, altitudes };
|
||||||
|
}
|
||||||
|
|
||||||
type ElevatedPoint = [number, number, number];
|
type ElevatedPoint = [number, number, number];
|
||||||
|
|
||||||
function smoothElevatedPath(
|
function smoothElevatedPath(
|
||||||
@ -686,19 +718,19 @@ export function FlightLayers({
|
|||||||
const curr = currSnapshotsRef.current.get(f.icao24);
|
const curr = currSnapshotsRef.current.get(f.icao24);
|
||||||
if (!curr) return f;
|
if (!curr) return f;
|
||||||
|
|
||||||
let prev = prevSnapshotsRef.current.get(f.icao24);
|
const prev = prevSnapshotsRef.current.get(f.icao24);
|
||||||
|
// For newly-loaded aircraft we may not have a real previous snapshot yet.
|
||||||
|
// Avoid synthesizing a fake motion vector from heading/velocity because it
|
||||||
|
// can briefly animate aircraft in the wrong direction until the next poll.
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
const rad = (curr.track * Math.PI) / 180;
|
return {
|
||||||
const spd = Number.isFinite(f.velocity) ? f.velocity! : 200;
|
...f,
|
||||||
const step = Math.min(
|
longitude: curr.lng,
|
||||||
(spd * (animDurationRef.current / 1000)) / 111_320,
|
latitude: curr.lat,
|
||||||
0.015,
|
baroAltitude: curr.alt,
|
||||||
);
|
trueTrack: Number.isFinite(f.trueTrack)
|
||||||
prev = {
|
? f.trueTrack!
|
||||||
lng: curr.lng - Math.sin(rad) * step,
|
: curr.track,
|
||||||
lat: curr.lat - Math.cos(rad) * step,
|
|
||||||
alt: curr.alt,
|
|
||||||
track: curr.track,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -861,19 +893,69 @@ export function FlightLayers({
|
|||||||
trail: TrailEntry,
|
trail: TrailEntry,
|
||||||
animFlight: FlightState | undefined,
|
animFlight: FlightState | undefined,
|
||||||
): ElevatedPoint[] => {
|
): ElevatedPoint[] => {
|
||||||
const historyPoints = Math.max(
|
const isFullHistory = trail.fullHistory === true;
|
||||||
2,
|
const historyPoints = isFullHistory
|
||||||
Math.round(trailDistanceRef.current),
|
? trail.path.length
|
||||||
|
: Math.max(2, Math.round(trailDistanceRef.current));
|
||||||
|
|
||||||
|
let pathSlice =
|
||||||
|
isFullHistory || trail.path.length <= historyPoints
|
||||||
|
? trail.path
|
||||||
|
: trail.path.slice(trail.path.length - historyPoints);
|
||||||
|
let altitudeSlice =
|
||||||
|
isFullHistory || trail.altitudes.length <= historyPoints
|
||||||
|
? trail.altitudes
|
||||||
|
: trail.altitudes.slice(trail.altitudes.length - historyPoints);
|
||||||
|
|
||||||
|
// Keep full-history rendering performant by limiting point count.
|
||||||
|
if (isFullHistory) {
|
||||||
|
const MAX_FULL_HISTORY_POINTS = 1200;
|
||||||
|
if (pathSlice.length > MAX_FULL_HISTORY_POINTS) {
|
||||||
|
const stride = pathSlice.length / MAX_FULL_HISTORY_POINTS;
|
||||||
|
const nextPath: [number, number][] = [];
|
||||||
|
const nextAlt: Array<number | null> = [];
|
||||||
|
for (let i = 0; i < MAX_FULL_HISTORY_POINTS - 1; i++) {
|
||||||
|
const idx = Math.floor(i * stride);
|
||||||
|
nextPath.push(pathSlice[idx]);
|
||||||
|
nextAlt.push(altitudeSlice[idx] ?? null);
|
||||||
|
}
|
||||||
|
nextPath.push(pathSlice[pathSlice.length - 1]);
|
||||||
|
nextAlt.push(altitudeSlice[altitudeSlice.length - 1] ?? null);
|
||||||
|
pathSlice = nextPath;
|
||||||
|
altitudeSlice = nextAlt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (altitudeSlice.length !== pathSlice.length) {
|
||||||
|
const last = altitudeSlice[altitudeSlice.length - 1] ?? null;
|
||||||
|
if (altitudeSlice.length < pathSlice.length) {
|
||||||
|
altitudeSlice = [...altitudeSlice];
|
||||||
|
while (altitudeSlice.length < pathSlice.length) {
|
||||||
|
altitudeSlice.push(last);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
altitudeSlice = altitudeSlice.slice(
|
||||||
|
altitudeSlice.length - pathSlice.length,
|
||||||
);
|
);
|
||||||
const pathSlice =
|
}
|
||||||
trail.path.length > historyPoints
|
}
|
||||||
? trail.path.slice(trail.path.length - historyPoints)
|
|
||||||
: trail.path;
|
const unwrappedPath = unwrapLngPath(pathSlice);
|
||||||
const altitudeSlice =
|
const maxJumpDeg = isFullHistory ? 3.0 : TELEPORT_THRESHOLD;
|
||||||
trail.altitudes.length > historyPoints
|
const trimmed = trimAfterLargeJump(
|
||||||
? trail.altitudes.slice(trail.altitudes.length - historyPoints)
|
unwrappedPath,
|
||||||
: trail.altitudes;
|
altitudeSlice,
|
||||||
const smoothPathSlice = smoothPlanarPath(pathSlice);
|
maxJumpDeg,
|
||||||
|
);
|
||||||
|
pathSlice = trimmed.path;
|
||||||
|
altitudeSlice = trimmed.altitudes;
|
||||||
|
|
||||||
|
// The OpenSky track endpoint can be extremely sparse (waypoints ~ every 15min).
|
||||||
|
// Applying planar smoothing to sparse points can create visible kinks/loops.
|
||||||
|
// For full-history tracks, keep the raw geometry.
|
||||||
|
const smoothPathSlice = isFullHistory
|
||||||
|
? pathSlice
|
||||||
|
: smoothPlanarPath(pathSlice);
|
||||||
|
|
||||||
const altitudeMeters = smoothNumericSeries(
|
const altitudeMeters = smoothNumericSeries(
|
||||||
altitudeSlice.map(
|
altitudeSlice.map(
|
||||||
@ -888,7 +970,7 @@ export function FlightLayers({
|
|||||||
]) as ElevatedPoint[];
|
]) as ElevatedPoint[];
|
||||||
const denseBasePath = densifyElevatedPath(
|
const denseBasePath = densifyElevatedPath(
|
||||||
basePath,
|
basePath,
|
||||||
denseSubdivisions,
|
isFullHistory ? 1 : denseSubdivisions,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -897,8 +979,13 @@ export function FlightLayers({
|
|||||||
animFlight.latitude != null &&
|
animFlight.latitude != null &&
|
||||||
denseBasePath.length > 1
|
denseBasePath.length > 1
|
||||||
) {
|
) {
|
||||||
const clipped = trimPathAheadOfAircraft(denseBasePath, [
|
const refLng = denseBasePath[denseBasePath.length - 1][0];
|
||||||
|
const snappedLng = snapLngToReference(
|
||||||
animFlight.longitude,
|
animFlight.longitude,
|
||||||
|
refLng,
|
||||||
|
);
|
||||||
|
const clipped = trimPathAheadOfAircraft(denseBasePath, [
|
||||||
|
snappedLng,
|
||||||
animFlight.latitude,
|
animFlight.latitude,
|
||||||
Math.max(0, animFlight.baroAltitude ?? 0),
|
Math.max(0, animFlight.baroAltitude ?? 0),
|
||||||
]);
|
]);
|
||||||
@ -906,7 +993,10 @@ export function FlightLayers({
|
|||||||
const smoothed =
|
const smoothed =
|
||||||
clipped.length < 4
|
clipped.length < 4
|
||||||
? clipped
|
? clipped
|
||||||
: smoothElevatedPath(clipped, smoothingIterations);
|
: smoothElevatedPath(
|
||||||
|
clipped,
|
||||||
|
isFullHistory ? 0 : smoothingIterations,
|
||||||
|
);
|
||||||
|
|
||||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||||
}
|
}
|
||||||
@ -914,7 +1004,10 @@ export function FlightLayers({
|
|||||||
const smoothed =
|
const smoothed =
|
||||||
denseBasePath.length < 4
|
denseBasePath.length < 4
|
||||||
? denseBasePath
|
? denseBasePath
|
||||||
: smoothElevatedPath(denseBasePath, smoothingIterations);
|
: smoothElevatedPath(
|
||||||
|
denseBasePath,
|
||||||
|
isFullHistory ? 0 : smoothingIterations,
|
||||||
|
);
|
||||||
|
|
||||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -32,12 +32,18 @@ import { formatCallsign } from "@/lib/flight-utils";
|
|||||||
|
|
||||||
type TabId = "search" | "style" | "settings";
|
type TabId = "search" | "style" | "settings";
|
||||||
|
|
||||||
const TABS: { id: TabId; icon: typeof Search; label: string }[] = [
|
const MAIN_TABS: {
|
||||||
|
id: TabId;
|
||||||
|
icon: typeof Search;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
{ id: "search", icon: Search, label: "Search" },
|
{ id: "search", icon: Search, label: "Search" },
|
||||||
{ id: "style", icon: MapIcon, label: "Map Style" },
|
{ id: "style", icon: MapIcon, label: "Map Style" },
|
||||||
{ id: "settings", icon: Settings, label: "Settings" },
|
{ id: "settings", icon: Settings, label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PANEL_TABS = MAIN_TABS;
|
||||||
|
|
||||||
type ControlPanelProps = {
|
type ControlPanelProps = {
|
||||||
activeCity: City;
|
activeCity: City;
|
||||||
onSelectCity: (city: City) => void;
|
onSelectCity: (city: City) => void;
|
||||||
@ -73,7 +79,7 @@ export function ControlPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{TABS.map(({ id, icon: Icon, label }) => (
|
{MAIN_TABS.map(({ id, icon: Icon, label }) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => open(id)}
|
onClick={() => open(id)}
|
||||||
@ -218,7 +224,7 @@ function PanelDialog({
|
|||||||
Controls
|
Controls
|
||||||
</p>
|
</p>
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{TABS.map(({ id, icon: Icon, label }) => {
|
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
|
||||||
const active = id === activeTab;
|
const active = id === activeTab;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -279,7 +285,7 @@ function PanelDialog({
|
|||||||
id="panel-dialog-title"
|
id="panel-dialog-title"
|
||||||
className="text-[14px] font-semibold tracking-tight text-white/90"
|
className="text-[14px] font-semibold tracking-tight text-white/90"
|
||||||
>
|
>
|
||||||
{TABS.find((t) => t.id === activeTab)?.label}
|
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{/* Desktop header */}
|
{/* Desktop header */}
|
||||||
@ -288,7 +294,7 @@ function PanelDialog({
|
|||||||
id="panel-dialog-title"
|
id="panel-dialog-title"
|
||||||
className="text-[15px] font-semibold tracking-tight text-white/90"
|
className="text-[15px] font-semibold tracking-tight text-white/90"
|
||||||
>
|
>
|
||||||
{TABS.find((t) => t.id === activeTab)?.label}
|
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
|
||||||
</h2>
|
</h2>
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@ -338,7 +344,7 @@ function PanelDialog({
|
|||||||
{/* Mobile tab bar */}
|
{/* Mobile tab bar */}
|
||||||
<div className="flex sm:hidden items-center gap-1 border-t border-white/6 px-3 pt-2 pb-3">
|
<div className="flex sm:hidden items-center gap-1 border-t border-white/6 px-3 pt-2 pb-3">
|
||||||
<nav className="flex flex-1 gap-1">
|
<nav className="flex flex-1 gap-1">
|
||||||
{TABS.map(({ id, icon: Icon, label }) => {
|
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
|
||||||
const active = id === activeTab;
|
const active = id === activeTab;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import {
|
import {
|
||||||
Plane,
|
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Gauge,
|
Gauge,
|
||||||
@ -25,6 +24,11 @@ import {
|
|||||||
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
||||||
import { aircraftTypeHint } from "@/lib/aircraft";
|
import { aircraftTypeHint } from "@/lib/aircraft";
|
||||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||||
|
import {
|
||||||
|
loadedAirlineLogoUrls,
|
||||||
|
markAirlineLogoFailed,
|
||||||
|
wasAirlineLogoRecentlyFailed,
|
||||||
|
} from "@/lib/logo-cache";
|
||||||
|
|
||||||
type FlightCardProps = {
|
type FlightCardProps = {
|
||||||
flight: FlightState | null;
|
flight: FlightState | null;
|
||||||
@ -33,8 +37,6 @@ type FlightCardProps = {
|
|||||||
isFpvActive?: boolean;
|
isFpvActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadedLogoUrls = new Set<string>();
|
|
||||||
|
|
||||||
export function FlightCard({
|
export function FlightCard({
|
||||||
flight,
|
flight,
|
||||||
onClose,
|
onClose,
|
||||||
@ -60,14 +62,27 @@ export function FlightCard({
|
|||||||
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
const [genericLogoFailed, setGenericLogoFailed] = useState(false);
|
||||||
const airlineKey = airline ?? "__none__";
|
const airlineKey = airline ?? "__none__";
|
||||||
const logoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
const baseLogoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||||
const logoLoadKey = `${airlineKey}:${logoIndex}`;
|
const resolvedLogoIndex = useMemo(() => {
|
||||||
const logoUrl = logoCandidates[logoIndex] ?? null;
|
let idx = baseLogoIndex;
|
||||||
|
while (
|
||||||
|
idx < logoCandidates.length &&
|
||||||
|
wasAirlineLogoRecentlyFailed(logoCandidates[idx] ?? "")
|
||||||
|
) {
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}, [baseLogoIndex, logoCandidates]);
|
||||||
|
|
||||||
|
const logoLoadKey = `${airlineKey}:${resolvedLogoIndex}`;
|
||||||
|
const logoUrl = logoCandidates[resolvedLogoIndex] ?? null;
|
||||||
const logoLoaded =
|
const logoLoaded =
|
||||||
(logoUrl ? loadedLogoUrls.has(logoUrl) : false) ||
|
(logoUrl ? loadedAirlineLogoUrls.has(logoUrl) : false) ||
|
||||||
(logoLoadedByKey[logoLoadKey] ?? false);
|
(logoLoadedByKey[logoLoadKey] ?? false);
|
||||||
const showLogo = Boolean(logoUrl);
|
const showLogo = Boolean(logoUrl);
|
||||||
|
const genericLogoUrl = "/airline-logos/envoy-air.png";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@ -110,17 +125,19 @@ export function FlightCard({
|
|||||||
}`}
|
}`}
|
||||||
unoptimized
|
unoptimized
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (logoUrl) loadedLogoUrls.add(logoUrl);
|
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||||
setLogoLoadedByKey((current) => ({
|
setLogoLoadedByKey((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[logoLoadKey]: true,
|
[logoLoadKey]: true,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
if (logoIndex + 1 < logoCandidates.length) {
|
if (logoUrl) markAirlineLogoFailed(logoUrl);
|
||||||
|
|
||||||
|
if (resolvedLogoIndex + 1 < logoCandidates.length) {
|
||||||
setLogoIndexByAirline((current) => ({
|
setLogoIndexByAirline((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[airlineKey]: logoIndex + 1,
|
[airlineKey]: resolvedLogoIndex + 1,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -132,7 +149,23 @@ export function FlightCard({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Plane className="h-10 w-10 text-sky-400/85" />
|
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-white/95 p-3.5 shadow-sm">
|
||||||
|
{genericLogoFailed ? (
|
||||||
|
<span className="text-[22px] font-semibold text-black/25">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={genericLogoUrl}
|
||||||
|
alt="Generic airline logo"
|
||||||
|
width={68}
|
||||||
|
height={68}
|
||||||
|
className="h-13 w-13 object-contain grayscale opacity-80"
|
||||||
|
unoptimized
|
||||||
|
onError={() => setGenericLogoFailed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -3,11 +3,16 @@
|
|||||||
import { useRef, useEffect, useMemo, useState } from "react";
|
import { useRef, useEffect, useMemo, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { X, Eye, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
|
import { X, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
|
||||||
import type { FlightState } from "@/lib/opensky";
|
import type { FlightState } from "@/lib/opensky";
|
||||||
import { formatCallsign, headingToCardinal } from "@/lib/flight-utils";
|
import { formatCallsign, headingToCardinal } from "@/lib/flight-utils";
|
||||||
import { lookupAirline } from "@/lib/airlines";
|
import { lookupAirline } from "@/lib/airlines";
|
||||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||||
|
import {
|
||||||
|
loadedAirlineLogoUrls,
|
||||||
|
markAirlineLogoFailed,
|
||||||
|
wasAirlineLogoRecentlyFailed,
|
||||||
|
} from "@/lib/logo-cache";
|
||||||
|
|
||||||
type FpvHudProps = {
|
type FpvHudProps = {
|
||||||
flight: FlightState;
|
flight: FlightState;
|
||||||
@ -139,11 +144,39 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
|
|||||||
() => lookupAirline(flight.callsign),
|
() => lookupAirline(flight.callsign),
|
||||||
[flight.callsign],
|
[flight.callsign],
|
||||||
);
|
);
|
||||||
const logoUrl = useMemo(() => {
|
const logoCandidates = useMemo(
|
||||||
return airlineLogoCandidates(airline)[0] ?? null;
|
() => airlineLogoCandidates(airline),
|
||||||
}, [airline]);
|
[airline],
|
||||||
const [logoErrorUrl, setLogoErrorUrl] = useState<string | null>(null);
|
);
|
||||||
const logoError = logoUrl !== null && logoUrl === logoErrorUrl;
|
const airlineKey = airline ?? "__none__";
|
||||||
|
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
|
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [genericFailedByAirline, setGenericFailedByAirline] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const baseLogoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||||
|
const resolvedLogoIndex = useMemo(() => {
|
||||||
|
let idx = baseLogoIndex;
|
||||||
|
while (
|
||||||
|
idx < logoCandidates.length &&
|
||||||
|
wasAirlineLogoRecentlyFailed(logoCandidates[idx] ?? "")
|
||||||
|
) {
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}, [baseLogoIndex, logoCandidates]);
|
||||||
|
const logoUrl = logoCandidates[resolvedLogoIndex] ?? null;
|
||||||
|
const logoLoadKey = `${airlineKey}:${resolvedLogoIndex}`;
|
||||||
|
const logoLoaded =
|
||||||
|
logoUrl !== null &&
|
||||||
|
(loadedAirlineLogoUrls.has(logoUrl) ||
|
||||||
|
(logoLoadedByKey[logoLoadKey] ?? false));
|
||||||
|
const genericLogoFailed = genericFailedByAirline[airlineKey] ?? false;
|
||||||
|
const genericLogoUrl = "/airline-logos/envoy-air.png";
|
||||||
const vsIcon =
|
const vsIcon =
|
||||||
vs !== null && Number.isFinite(vs) ? (
|
vs !== null && Number.isFinite(vs) ? (
|
||||||
vs > 0.5 ? (
|
vs > 0.5 ? (
|
||||||
@ -183,21 +216,68 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
|
|||||||
|
|
||||||
<div className="flex w-full items-stretch">
|
<div className="flex w-full items-stretch">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 border-r border-white/6 px-2 py-1.5 sm:px-3 sm:py-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2 border-r border-white/6 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||||
{logoUrl && !logoError ? (
|
{logoUrl ? (
|
||||||
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/95 shadow-sm ring-1 ring-white/20">
|
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/95 shadow-sm ring-1 ring-white/20">
|
||||||
|
{!logoLoaded && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/85 via-neutral-200/65 to-white/80"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Image
|
<Image
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt={airline ? `${airline} logo` : "Airline logo"}
|
alt={airline ? `${airline} logo` : "Airline logo"}
|
||||||
fill
|
fill
|
||||||
sizes="32px"
|
sizes="32px"
|
||||||
className="object-contain p-1"
|
className="relative object-contain p-1"
|
||||||
unoptimized
|
unoptimized
|
||||||
onError={() => setLogoErrorUrl(logoUrl)}
|
onLoad={() => {
|
||||||
|
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||||
|
setLogoLoadedByKey((current) => ({
|
||||||
|
...current,
|
||||||
|
[logoLoadKey]: true,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
if (logoUrl) markAirlineLogoFailed(logoUrl);
|
||||||
|
if (resolvedLogoIndex + 1 < logoCandidates.length) {
|
||||||
|
setLogoIndexByAirline((current) => ({
|
||||||
|
...current,
|
||||||
|
[airlineKey]: resolvedLogoIndex + 1,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoIndexByAirline((current) => ({
|
||||||
|
...current,
|
||||||
|
[airlineKey]: logoCandidates.length,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 ring-1 ring-white/10">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 ring-1 ring-white/10">
|
||||||
<Eye className="h-3.5 w-3.5 text-emerald-400/70 animate-pulse" />
|
<span className="relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full bg-white/95 ring-1 ring-white/15">
|
||||||
|
{genericLogoFailed ? (
|
||||||
|
<span className="text-[12px] font-semibold text-black/25">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={genericLogoUrl}
|
||||||
|
alt="Generic airline logo"
|
||||||
|
fill
|
||||||
|
sizes="28px"
|
||||||
|
className="object-contain p-1 grayscale opacity-80"
|
||||||
|
unoptimized
|
||||||
|
onError={() =>
|
||||||
|
setGenericFailedByAirline((current) => ({
|
||||||
|
...current,
|
||||||
|
[airlineKey]: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@ -18,8 +18,17 @@ export function MapAttribution({ styleId }: MapAttributionProps) {
|
|||||||
|
|
||||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||||
|
|
||||||
|
// Expand by default on larger screens (after mount to avoid hydration mismatch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpanded(window.innerWidth >= SM_BREAKPOINT);
|
const mq = window.matchMedia(`(min-width: ${SM_BREAKPOINT}px)`);
|
||||||
|
const sync = () => setExpanded(mq.matches);
|
||||||
|
const raf = window.requestAnimationFrame(sync);
|
||||||
|
|
||||||
|
mq.addEventListener("change", sync);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf);
|
||||||
|
mq.removeEventListener("change", sync);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close on outside click for small screens
|
// Close on outside click for small screens
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { Compass, Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
import { Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
type StatusBarProps = {
|
type StatusBarProps = {
|
||||||
flightCount: number;
|
flightCount: number;
|
||||||
@ -138,7 +138,14 @@ export function StatusBar({
|
|||||||
className="text-[11px] font-medium tracking-wide transition-colors"
|
className="text-[11px] font-medium tracking-wide transition-colors"
|
||||||
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
|
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
|
||||||
>
|
>
|
||||||
<Compass className="h-3 w-3" />
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 3L4 21l8-4 8 4L12 3z" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className="h-3 w-px"
|
className="h-3 w-px"
|
||||||
|
|||||||
209
src/hooks/use-flight-track.ts
Normal file
209
src/hooks/use-flight-track.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchTrackByIcao24, type FlightTrack } from "@/lib/opensky";
|
||||||
|
|
||||||
|
type TrackCacheEntry = {
|
||||||
|
fetchedAt: number;
|
||||||
|
nextAllowedAt: number;
|
||||||
|
track: FlightTrack | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// /tracks is expensive + rate-limited; cache aggressively.
|
||||||
|
const DEFAULT_REFRESH_MS = 0;
|
||||||
|
const TRACK_CACHE_TTL_MS_EFFECTIVE = 10 * 60_000;
|
||||||
|
const NEGATIVE_CACHE_TTL_MS_EFFECTIVE = 60_000;
|
||||||
|
|
||||||
|
const trackCache = new Map<string, TrackCacheEntry>();
|
||||||
|
|
||||||
|
// Global backoff for /tracks 429s.
|
||||||
|
let globalNextAllowedAt = 0;
|
||||||
|
let globalBackoffMs = 5 * 60_000;
|
||||||
|
const GLOBAL_BACKOFF_MAX_MS = 24 * 60 * 60_000;
|
||||||
|
const GLOBAL_BACKOFF_KEY = "aeris:opensky:tracksGlobalNextAllowedAt";
|
||||||
|
const GLOBAL_BACKOFF_MS_KEY = "aeris:opensky:tracksGlobalBackoffMs";
|
||||||
|
const SELECTION_DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
|
function loadGlobalBackoff(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const nextAllowedRaw = sessionStorage.getItem(GLOBAL_BACKOFF_KEY);
|
||||||
|
const nextAllowed = nextAllowedRaw ? Number.parseInt(nextAllowedRaw, 10) : 0;
|
||||||
|
if (Number.isFinite(nextAllowed) && nextAllowed > 0) {
|
||||||
|
globalNextAllowedAt = Math.max(globalNextAllowedAt, nextAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backoffRaw = sessionStorage.getItem(GLOBAL_BACKOFF_MS_KEY);
|
||||||
|
const backoff = backoffRaw ? Number.parseInt(backoffRaw, 10) : 0;
|
||||||
|
if (Number.isFinite(backoff) && backoff > 0) {
|
||||||
|
globalBackoffMs = Math.min(GLOBAL_BACKOFF_MAX_MS, Math.max(60_000, backoff));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistGlobalBackoff(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(GLOBAL_BACKOFF_KEY, String(globalNextAllowedAt));
|
||||||
|
sessionStorage.setItem(GLOBAL_BACKOFF_MS_KEY, String(globalBackoffMs));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheTtlMs(track: FlightTrack | null): number {
|
||||||
|
return track ? TRACK_CACHE_TTL_MS_EFFECTIVE : NEGATIVE_CACHE_TTL_MS_EFFECTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlightTrack(
|
||||||
|
icao24: string | null,
|
||||||
|
options?: {
|
||||||
|
refreshMs?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
},
|
||||||
|
): { track: FlightTrack | null; loading: boolean; fetchedAtMs: number } {
|
||||||
|
const refreshMs = options?.refreshMs ?? DEFAULT_REFRESH_MS;
|
||||||
|
const enabled = options?.enabled ?? true;
|
||||||
|
|
||||||
|
const [track, setTrack] = useState<FlightTrack | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fetchedAtMs, setFetchedAtMs] = useState(0);
|
||||||
|
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
const activeKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGlobalBackoff();
|
||||||
|
|
||||||
|
if (!icao24) {
|
||||||
|
setTrack(null);
|
||||||
|
setLoading(false);
|
||||||
|
setFetchedAtMs(0);
|
||||||
|
activeKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = icao24.trim().toLowerCase();
|
||||||
|
const isKeyChange = activeKeyRef.current !== key;
|
||||||
|
activeKeyRef.current = key;
|
||||||
|
|
||||||
|
const cached = trackCache.get(key);
|
||||||
|
const hasCachedTrack = cached?.track != null;
|
||||||
|
|
||||||
|
// Stale-while-revalidate: keep cached track visible.
|
||||||
|
if (hasCachedTrack) {
|
||||||
|
setTrack(cached!.track);
|
||||||
|
setFetchedAtMs(cached!.fetchedAt);
|
||||||
|
} else if (isKeyChange) {
|
||||||
|
setTrack(null);
|
||||||
|
setFetchedAtMs(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alive = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now < globalNextAllowedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = trackCache.get(key);
|
||||||
|
if (existing && now < existing.nextAllowedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && now - existing.fetchedAt <= cacheTtlMs(existing.track)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchTrackByIcao24(key, 0, controller.signal);
|
||||||
|
if (!alive || requestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
|
const fetchedAt = Date.now();
|
||||||
|
const retryAfterSeconds =
|
||||||
|
typeof result.retryAfterSeconds === "number" &&
|
||||||
|
Number.isFinite(result.retryAfterSeconds)
|
||||||
|
? result.retryAfterSeconds
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rateLimitedBackoffMs =
|
||||||
|
retryAfterSeconds && retryAfterSeconds > 0
|
||||||
|
? Math.max(1, retryAfterSeconds) * 1000
|
||||||
|
: globalBackoffMs;
|
||||||
|
|
||||||
|
const nextAllowedAt = result.rateLimited
|
||||||
|
? fetchedAt + rateLimitedBackoffMs
|
||||||
|
: fetchedAt;
|
||||||
|
|
||||||
|
if (result.rateLimited) {
|
||||||
|
globalNextAllowedAt = Math.max(globalNextAllowedAt, nextAllowedAt);
|
||||||
|
globalBackoffMs = Math.min(
|
||||||
|
GLOBAL_BACKOFF_MAX_MS,
|
||||||
|
Math.max(60_000, Math.floor(globalBackoffMs * 1.6)),
|
||||||
|
);
|
||||||
|
persistGlobalBackoff();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = trackCache.get(key)?.track ?? null;
|
||||||
|
const nextTrack = result.track ?? existing;
|
||||||
|
|
||||||
|
trackCache.set(key, {
|
||||||
|
fetchedAt,
|
||||||
|
nextAllowedAt,
|
||||||
|
track: nextTrack,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFetchedAtMs(fetchedAt);
|
||||||
|
|
||||||
|
setTrack(nextTrack);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.error("useFlightTrack: failed to fetch track", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (alive && requestId === requestIdRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceMs = isKeyChange ? SELECTION_DEBOUNCE_MS : 0;
|
||||||
|
const loadTimer = window.setTimeout(() => {
|
||||||
|
void load();
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
let interval: number | null = null;
|
||||||
|
if (refreshMs > 0) {
|
||||||
|
interval = window.setInterval(() => {
|
||||||
|
void load();
|
||||||
|
}, refreshMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
controller.abort();
|
||||||
|
window.clearTimeout(loadTimer);
|
||||||
|
if (interval !== null) window.clearInterval(interval);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
}, [icao24, refreshMs, enabled]);
|
||||||
|
|
||||||
|
return { track, loading, fetchedAtMs };
|
||||||
|
}
|
||||||
@ -110,6 +110,10 @@ export function useFlights(
|
|||||||
const scheduleNext = useCallback(
|
const scheduleNext = useCallback(
|
||||||
(target: City, delayMs: number) => {
|
(target: City, delayMs: number) => {
|
||||||
clearSchedule();
|
clearSchedule();
|
||||||
|
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
fetchDataRef.current(target);
|
fetchDataRef.current(target);
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
@ -220,7 +224,12 @@ export function useFlights(
|
|||||||
const activeCity = city;
|
const activeCity = city;
|
||||||
|
|
||||||
function onVisibilityChange() {
|
function onVisibilityChange() {
|
||||||
if (document.visibilityState !== "visible") return;
|
if (document.visibilityState !== "visible") {
|
||||||
|
// Fully pause polling while hidden.
|
||||||
|
clearSchedule();
|
||||||
|
abortRef.current?.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const elapsed = Date.now() - lastFetchRef.current;
|
const elapsed = Date.now() - lastFetchRef.current;
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export type TrailEntry = {
|
|||||||
path: Position[];
|
path: Position[];
|
||||||
altitudes: Array<number | null>;
|
altitudes: Array<number | null>;
|
||||||
baroAltitude: number | null;
|
baroAltitude: number | null;
|
||||||
|
fullHistory?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_POINTS = 40;
|
const MAX_POINTS = 40;
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const CITIES: City[] = [
|
|||||||
country: "US",
|
country: "US",
|
||||||
iata: "JFK",
|
iata: "JFK",
|
||||||
coordinates: [-73.7781, 40.6413],
|
coordinates: [-73.7781, 40.6413],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lax",
|
id: "lax",
|
||||||
@ -22,7 +22,7 @@ export const CITIES: City[] = [
|
|||||||
country: "US",
|
country: "US",
|
||||||
iata: "LAX",
|
iata: "LAX",
|
||||||
coordinates: [-118.4085, 33.9416],
|
coordinates: [-118.4085, 33.9416],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lhr",
|
id: "lhr",
|
||||||
@ -30,7 +30,7 @@ export const CITIES: City[] = [
|
|||||||
country: "GB",
|
country: "GB",
|
||||||
iata: "LHR",
|
iata: "LHR",
|
||||||
coordinates: [-0.4614, 51.47],
|
coordinates: [-0.4614, 51.47],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dxb",
|
id: "dxb",
|
||||||
@ -38,7 +38,7 @@ export const CITIES: City[] = [
|
|||||||
country: "AE",
|
country: "AE",
|
||||||
iata: "DXB",
|
iata: "DXB",
|
||||||
coordinates: [55.3644, 25.2532],
|
coordinates: [55.3644, 25.2532],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "nrt",
|
id: "nrt",
|
||||||
@ -46,7 +46,7 @@ export const CITIES: City[] = [
|
|||||||
country: "JP",
|
country: "JP",
|
||||||
iata: "NRT",
|
iata: "NRT",
|
||||||
coordinates: [140.3929, 35.772],
|
coordinates: [140.3929, 35.772],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sin",
|
id: "sin",
|
||||||
@ -54,7 +54,7 @@ export const CITIES: City[] = [
|
|||||||
country: "SG",
|
country: "SG",
|
||||||
iata: "SIN",
|
iata: "SIN",
|
||||||
coordinates: [103.9915, 1.3644],
|
coordinates: [103.9915, 1.3644],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cdg",
|
id: "cdg",
|
||||||
@ -62,7 +62,7 @@ export const CITIES: City[] = [
|
|||||||
country: "FR",
|
country: "FR",
|
||||||
iata: "CDG",
|
iata: "CDG",
|
||||||
coordinates: [2.5479, 49.0097],
|
coordinates: [2.5479, 49.0097],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sfo",
|
id: "sfo",
|
||||||
@ -70,7 +70,7 @@ export const CITIES: City[] = [
|
|||||||
country: "US",
|
country: "US",
|
||||||
iata: "SFO",
|
iata: "SFO",
|
||||||
coordinates: [-122.379, 37.6213],
|
coordinates: [-122.379, 37.6213],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ord",
|
id: "ord",
|
||||||
@ -78,7 +78,7 @@ export const CITIES: City[] = [
|
|||||||
country: "US",
|
country: "US",
|
||||||
iata: "ORD",
|
iata: "ORD",
|
||||||
coordinates: [-87.9073, 41.9742],
|
coordinates: [-87.9073, 41.9742],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fra",
|
id: "fra",
|
||||||
@ -86,7 +86,7 @@ export const CITIES: City[] = [
|
|||||||
country: "DE",
|
country: "DE",
|
||||||
iata: "FRA",
|
iata: "FRA",
|
||||||
coordinates: [8.5622, 50.0379],
|
coordinates: [8.5622, 50.0379],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bom",
|
id: "bom",
|
||||||
@ -94,7 +94,7 @@ export const CITIES: City[] = [
|
|||||||
country: "IN",
|
country: "IN",
|
||||||
iata: "BOM",
|
iata: "BOM",
|
||||||
coordinates: [72.8679, 19.0896],
|
coordinates: [72.8679, 19.0896],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mia",
|
id: "mia",
|
||||||
@ -102,6 +102,6 @@ export const CITIES: City[] = [
|
|||||||
country: "US",
|
country: "US",
|
||||||
iata: "MIA",
|
iata: "MIA",
|
||||||
coordinates: [-80.2906, 25.7959],
|
coordinates: [-80.2906, 25.7959],
|
||||||
radius: 2.5,
|
radius: 2.49,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
23
src/lib/geo.ts
Normal file
23
src/lib/geo.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export function snapLngToReference(lng: number, refLng: number): number {
|
||||||
|
if (!Number.isFinite(lng) || !Number.isFinite(refLng)) return lng;
|
||||||
|
let x = lng;
|
||||||
|
while (x - refLng > 180) x -= 360;
|
||||||
|
while (x - refLng < -180) x += 360;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapLngPath(
|
||||||
|
path: Array<[lng: number, lat: number]>,
|
||||||
|
): Array<[lng: number, lat: number]> {
|
||||||
|
if (path.length < 2) return path.slice();
|
||||||
|
const [firstLng, firstLat] = path[0];
|
||||||
|
const out: Array<[number, number]> = [[firstLng, firstLat]];
|
||||||
|
let refLng = firstLng;
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const [lng, lat] = path[i];
|
||||||
|
const nextLng = snapLngToReference(lng, refLng);
|
||||||
|
out.push([nextLng, lat]);
|
||||||
|
refLng = nextLng;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
41
src/lib/logo-cache.ts
Normal file
41
src/lib/logo-cache.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export const loadedAirlineLogoUrls = new Set<string>();
|
||||||
|
|
||||||
|
const FAILED_TTL_MS = 10 * 60_000;
|
||||||
|
const MAX_FAILED_ENTRIES = 500;
|
||||||
|
const failedAirlineLogoTimestamps = new Map<string, number>();
|
||||||
|
|
||||||
|
export function wasAirlineLogoRecentlyFailed(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
const ts = failedAirlineLogoTimestamps.get(url);
|
||||||
|
if (ts === undefined) return false;
|
||||||
|
if (Date.now() - ts > FAILED_TTL_MS) {
|
||||||
|
failedAirlineLogoTimestamps.delete(url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAirlineLogoFailed(url: string): void {
|
||||||
|
if (!url) return;
|
||||||
|
const now = Date.now();
|
||||||
|
failedAirlineLogoTimestamps.set(url, now);
|
||||||
|
|
||||||
|
// Opportunistically prune expired entries so the cache doesn't skew toward old URLs.
|
||||||
|
for (const [key, ts] of failedAirlineLogoTimestamps) {
|
||||||
|
if (now - ts > FAILED_TTL_MS) {
|
||||||
|
failedAirlineLogoTimestamps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedAirlineLogoTimestamps.size <= MAX_FAILED_ENTRIES) return;
|
||||||
|
|
||||||
|
let oldestUrl: string | null = null;
|
||||||
|
let oldestTs = Number.POSITIVE_INFINITY;
|
||||||
|
for (const [key, ts] of failedAirlineLogoTimestamps) {
|
||||||
|
if (ts < oldestTs) {
|
||||||
|
oldestTs = ts;
|
||||||
|
oldestUrl = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestUrl) failedAirlineLogoTimestamps.delete(oldestUrl);
|
||||||
|
}
|
||||||
@ -3,7 +3,12 @@
|
|||||||
const OPENSKY_API = "https://opensky-network.org/api";
|
const OPENSKY_API = "https://opensky-network.org/api";
|
||||||
const FETCH_TIMEOUT_MS = 15_000;
|
const FETCH_TIMEOUT_MS = 15_000;
|
||||||
const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
|
const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
|
||||||
const CALLSIGN_CACHE_TTL_MS = 30_000;
|
// Callsign lookup scans global /states/all (4 credits); cache longer to reduce spikes.
|
||||||
|
const CALLSIGN_CACHE_TTL_MS = 2 * 60_000;
|
||||||
|
const CALLSIGN_CACHE_MAX_ENTRIES = 200;
|
||||||
|
|
||||||
|
// Keep bbox queries inside OpenSky's 0–25 sq-deg (1 credit) tier.
|
||||||
|
const MAX_1_CREDIT_RADIUS_DEG = 2.49;
|
||||||
|
|
||||||
export type FlightState = {
|
export type FlightState = {
|
||||||
icao24: string;
|
icao24: string;
|
||||||
@ -207,7 +212,16 @@ export function bboxFromCenter(
|
|||||||
lat: number,
|
lat: number,
|
||||||
radiusDeg: number,
|
radiusDeg: number,
|
||||||
): [lamin: number, lamax: number, lomin: number, lomax: number] {
|
): [lamin: number, lamax: number, lomin: number, lomax: number] {
|
||||||
return [lat - radiusDeg, lat + radiusDeg, lng - radiusDeg, lng + radiusDeg];
|
// 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);
|
||||||
|
return [
|
||||||
|
lat - safeRadius,
|
||||||
|
lat + safeRadius,
|
||||||
|
lng - safeRadius,
|
||||||
|
lng + safeRadius,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -361,6 +375,10 @@ export async function fetchFlightByCallsign(
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
|
if (callsignLookupCache.size > CALLSIGN_CACHE_MAX_ENTRIES) {
|
||||||
|
const oldestKey = callsignLookupCache.keys().next().value as string | undefined;
|
||||||
|
if (oldestKey) callsignLookupCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -457,3 +475,258 @@ export async function fetchFlightsByRoute(
|
|||||||
retryAfterSeconds,
|
retryAfterSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TrackWaypoint = {
|
||||||
|
time: number;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
baroAltitude: number | null;
|
||||||
|
trueTrack: number | null;
|
||||||
|
onGround: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightTrack = {
|
||||||
|
icao24: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
callsign: string | null;
|
||||||
|
path: TrackWaypoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackFetchResult = {
|
||||||
|
track: FlightTrack | null;
|
||||||
|
rateLimited: boolean;
|
||||||
|
creditsRemaining: number | null;
|
||||||
|
retryAfterSeconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenSkyTrackResponse = {
|
||||||
|
icao24?: unknown;
|
||||||
|
startTime?: unknown;
|
||||||
|
endTime?: unknown;
|
||||||
|
callsign?: unknown;
|
||||||
|
// Defensive: accept a misspelled field name if present.
|
||||||
|
calllsign?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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