feat: add first person view (FPV) functionality and HUD (#9)

* feat: add first person view (FPV) functionality and HUD

- Updated FlightCard component to include FPV toggle button and state management.
- Introduced FpvHud component for displaying flight data in FPV mode.
- Enhanced useFlights hook to support FPV bounding box logic for fetching flights.
- Added keyboard shortcuts for toggling FPV mode.
- Updated settings to include FPV-related configurations (pitch, chase distance).
- Implemented major airports caching for improved performance.
- Added fetchFlightByIcao24 function for single aircraft state retrieval.

* Refactor CameraController and ControlPanel components; enhance flight search functionality

- Simplified CameraController by removing unused refs and effects, and centralized map interaction management.
- Updated ControlPanel to support flight lookup with new props and integrated flight search results.
- Enhanced SearchContent to include flight matching logic and improved user feedback for flight searches.
- Introduced caching for flight callsign lookups in OpenSky API integration to optimize performance.
- Removed unnecessary settings related to FPV pitch and free camera mode from use-settings hook.

* feat: enhance FPV functionality and improve flight data handling

- Added `projectLngLatElevationPixelDelta` function to calculate pixel deltas based on longitude, latitude, and elevation.
- Updated `CameraController` to utilize new FPV parameters and improve camera behavior during flight.
- Enhanced flight data handling in `FlightLayers` to ensure proper tracking and display of flight information.
- Improved UI components for better user experience, including adjustments to the FPV HUD and flight card.
- Added error handling for image loading in the control panel.
- Refactored altitude and speed calculations to ensure they handle non-finite values gracefully.
- Adjusted map attribution behavior for better responsiveness on different screen sizes.
This commit is contained in:
kew
2026-02-21 12:31:17 +05:30
committed by GitHub
parent e262bd730d
commit a08f1c7250
17 changed files with 2358 additions and 247 deletions

1
.gitignore vendored
View File

@ -44,3 +44,4 @@ next-env.d.ts
# local documentation # local documentation
docs.txt docs.txt
ROADMAP.local.md

View File

@ -8,13 +8,14 @@ import {
useRef, useRef,
useSyncExternalStore, useSyncExternalStore,
} from "react"; } from "react";
import { motion } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { ErrorBoundary } from "@/components/error-boundary"; import { ErrorBoundary } from "@/components/error-boundary";
import { Map } from "@/components/map/map"; import { Map as MapView } from "@/components/map/map";
import { CameraController } from "@/components/map/camera-controller"; import { CameraController } from "@/components/map/camera-controller";
import { AirportLayer } from "@/components/map/airport-layer"; import { AirportLayer } from "@/components/map/airport-layer";
import { FlightLayers } from "@/components/map/flight-layers"; import { FlightLayers } from "@/components/map/flight-layers";
import { FlightCard } from "@/components/ui/flight-card"; import { FlightCard } from "@/components/ui/flight-card";
import { FpvHud } from "@/components/ui/fpv-hud";
import { KeyboardShortcutsHelp } from "@/components/ui/keyboard-shortcuts-help"; import { KeyboardShortcutsHelp } from "@/components/ui/keyboard-shortcuts-help";
import { ControlPanel } from "@/components/ui/control-panel"; import { ControlPanel } from "@/components/ui/control-panel";
import { AltitudeLegend } from "@/components/ui/altitude-legend"; import { AltitudeLegend } from "@/components/ui/altitude-legend";
@ -28,7 +29,12 @@ import { useTrailHistory } from "@/hooks/use-trail-history";
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";
import type { FlightState } from "@/lib/opensky"; import {
fetchFlightByIcao24,
fetchFlightByCallsign,
type FlightState,
} from "@/lib/opensky";
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";
@ -65,6 +71,7 @@ const HIGH_TRAFFIC_IATA_SET = new Set<string>(HIGH_TRAFFIC_IATA);
const HIGH_TRAFFIC_AIRPORTS = AIRPORTS.filter((airport) => const HIGH_TRAFFIC_AIRPORTS = AIRPORTS.filter((airport) =>
HIGH_TRAFFIC_IATA_SET.has(airport.iata.toUpperCase()), HIGH_TRAFFIC_IATA_SET.has(airport.iata.toUpperCase()),
); );
const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
const subscribeNoop = () => () => {}; const subscribeNoop = () => () => {};
@ -107,12 +114,46 @@ function syncCityToUrl(city: City): void {
try { try {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("city", city.iata); url.searchParams.set("city", city.iata);
url.searchParams.delete("from");
url.searchParams.delete("to");
url.searchParams.delete("fpv");
window.history.replaceState(null, "", url.toString()); window.history.replaceState(null, "", url.toString());
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
function syncFpvToUrl(icao24: string | null, activeCity?: City): void {
if (typeof window === "undefined") return;
try {
const url = new URL(window.location.href);
if (icao24) {
url.searchParams.set("fpv", icao24);
url.searchParams.delete("city");
url.searchParams.delete("from");
url.searchParams.delete("to");
} else {
url.searchParams.delete("fpv");
if (activeCity) {
url.searchParams.set("city", activeCity.iata);
}
}
window.history.replaceState(null, "", url.toString());
} catch {
/* ignore */
}
}
function resolveInitialFpv(): string | null {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get("fpv")?.trim().toLowerCase();
return raw && /^[0-9a-f]{6}$/.test(raw) ? raw : null;
} catch {
return null;
}
}
function loadMapStyle(): MapStyle { function loadMapStyle(): MapStyle {
try { try {
const id = localStorage.getItem(STYLE_STORAGE_KEY); const id = localStorage.getItem(STYLE_STORAGE_KEY);
@ -157,6 +198,19 @@ function pickRandomAirportCity(excludeIata?: string): City {
return airportToCity(randomAirport); return airportToCity(randomAirport);
} }
function cityFromFlight(flight: FlightState): City | null {
if (flight.longitude == null || flight.latitude == null) return null;
const code = flight.icao24.toUpperCase();
return {
id: `trk-${flight.icao24}`,
name: `Flight ${code}`,
country: flight.originCountry || "Unknown",
iata: code.slice(0, 3),
coordinates: [flight.longitude, flight.latitude],
radius: 2,
};
}
function FlightTrackerInner() { function FlightTrackerInner() {
const hydratedCity = useSyncExternalStore( const hydratedCity = useSyncExternalStore(
subscribeNoop, subscribeNoop,
@ -174,6 +228,22 @@ function FlightTrackerInner() {
const [selectedIcao24, setSelectedIcao24] = useState<string | null>(null); const [selectedIcao24, setSelectedIcao24] = useState<string | null>(null);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [repoStars, setRepoStars] = useState<number | null>(null); const [repoStars, setRepoStars] = useState<number | null>(null);
const [followIcao24, setFollowIcao24] = useState<string | null>(null);
const [fpvIcao24, setFpvIcao24] = useState<string | null>(null);
const pendingFpvRef = useRef<string | null>(resolveInitialFpv());
const fpvPositionRef = useRef<{
lng: number;
lat: number;
alt: number;
track: number;
} | null>(null);
const [fpvSeedCenter, setFpvSeedCenter] = useState<{
lng: number;
lat: number;
} | null>(null);
const activeCity = cityOverride ?? hydratedCity; const activeCity = cityOverride ?? hydratedCity;
const mapStyle = styleOverride ?? hydratedStyle; const mapStyle = styleOverride ?? hydratedStyle;
@ -182,6 +252,8 @@ function FlightTrackerInner() {
const setActiveCity = useCallback((city: City) => { const setActiveCity = useCallback((city: City) => {
setCityOverride(city); setCityOverride(city);
setSelectedIcao24(null); setSelectedIcao24(null);
setFpvIcao24(null);
setFollowIcao24(null);
syncCityToUrl(city); syncCityToUrl(city);
}, []); }, []);
@ -189,24 +261,157 @@ function FlightTrackerInner() {
setStyleOverride(style); setStyleOverride(style);
saveMapStyle(style); saveMapStyle(style);
}, []); }, []);
const { flights, loading, rateLimited, retryIn } = useFlights(activeCity); const { flights, loading, rateLimited, retryIn } = useFlights(
const trails = useTrailHistory(flights); activeCity,
fpvIcao24,
fpvSeedCenter,
);
const displayFlights = flights;
const displayTrails = useTrailHistory(displayFlights);
const selectedFlight = useMemo(() => { const selectedFlight = useMemo(() => {
if (!selectedIcao24) return null; if (!selectedIcao24) return null;
return flights.find((f) => f.icao24 === selectedIcao24) ?? null; return (
}, [selectedIcao24, flights]); displayFlights.find((f) => f.icao24.toLowerCase() === selectedIcao24) ??
null
);
}, [selectedIcao24, displayFlights]);
const followFlight = useMemo(() => {
if (!followIcao24) return null;
return (
displayFlights.find((f) => f.icao24.toLowerCase() === followIcao24) ??
null
);
}, [followIcao24, displayFlights]);
const fpvFlight = useMemo(() => {
if (!fpvIcao24) return null;
return (
displayFlights.find((f) => f.icao24.toLowerCase() === fpvIcao24) ?? null
);
}, [fpvIcao24, displayFlights]);
const lastKnownFlightRef = useRef<FlightState | null>(null);
useEffect(() => { useEffect(() => {
if (selectedFlight) lastKnownFlightRef.current = selectedFlight; syncFpvToUrl(fpvIcao24, activeCity);
if (!selectedIcao24) lastKnownFlightRef.current = null; }, [fpvIcao24, activeCity]);
}, [selectedFlight, selectedIcao24]);
// Safe: ref only changes in the effect above, which runs after state-driven re-renders. const fpvLookupDoneRef = useRef(false);
const displayFlight = useEffect(() => {
// eslint-disable-next-line react-hooks/refs const pending = pendingFpvRef.current;
selectedFlight ?? (selectedIcao24 ? lastKnownFlightRef.current : null); if (!pending || fpvIcao24) return;
const match = displayFlights.find(
(f) => f.icao24.toLowerCase() === pending,
);
if (match && match.longitude != null && match.latitude != null) {
if (match.onGround) {
pendingFpvRef.current = null;
syncFpvToUrl(null, activeCity);
setSelectedIcao24(match.icao24);
return;
}
pendingFpvRef.current = null;
fpvLookupDoneRef.current = false;
setFpvSeedCenter({ lng: match.longitude, lat: match.latitude });
setFpvIcao24(pending);
setFollowIcao24(null);
return;
}
if (!fpvLookupDoneRef.current && displayFlights.length > 0) {
fpvLookupDoneRef.current = true;
const controller = new AbortController();
fetchFlightByIcao24(pending, controller.signal)
.then((result) => {
if (
result.flight &&
result.flight.longitude != null &&
result.flight.latitude != null &&
!result.flight.onGround &&
pendingFpvRef.current === pending
) {
const focusCity = cityFromFlight(result.flight);
if (focusCity) {
setCityOverride(focusCity);
}
setFpvSeedCenter({
lng: result.flight.longitude,
lat: result.flight.latitude,
});
pendingFpvRef.current = null;
setFpvIcao24(pending);
setFollowIcao24(null);
} else if (pendingFpvRef.current === pending) {
pendingFpvRef.current = null;
syncFpvToUrl(null, activeCity);
if (result.flight) {
setSelectedIcao24(result.flight.icao24);
}
}
})
.catch(() => {
if (pendingFpvRef.current === pending) {
pendingFpvRef.current = null;
}
});
return () => controller.abort();
}
}, [displayFlights, fpvIcao24, activeCity]);
const fpvFlightOrCached = fpvFlight;
const fpvMissCountRef = useRef(0);
useEffect(() => {
if (!fpvIcao24) {
fpvMissCountRef.current = 0;
return;
}
if (fpvFlight) {
fpvMissCountRef.current = 0;
if (fpvFlight.onGround) {
const exitIcao = fpvIcao24;
const timer = setTimeout(() => {
setSelectedIcao24(exitIcao);
setFpvIcao24(null);
}, 0);
return () => clearTimeout(timer);
}
} else {
if (!rateLimited) {
fpvMissCountRef.current += 1;
}
if (fpvMissCountRef.current >= 3) {
const exitIcao = fpvIcao24;
const timer = setTimeout(() => {
setSelectedIcao24(exitIcao);
setFpvIcao24(null);
}, 0);
return () => clearTimeout(timer);
}
}
}, [fpvIcao24, fpvFlight, rateLimited]);
const followMissCountRef = useRef(0);
useEffect(() => {
if (!followIcao24) {
followMissCountRef.current = 0;
return;
}
if (followFlight) {
followMissCountRef.current = 0;
} else {
followMissCountRef.current += 1;
if (followMissCountRef.current >= 3) {
const timer = setTimeout(() => setFollowIcao24(null), 0);
return () => clearTimeout(timer);
}
}
}, [followIcao24, followFlight]);
const displayFlight = selectedFlight;
const missingSinceRef = useRef<number | null>(null); const missingSinceRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
@ -218,17 +423,17 @@ function FlightTrackerInner() {
missingSinceRef.current = null; missingSinceRef.current = null;
return; return;
} }
// Flight is selected but not in the current flights list.
const now = Date.now(); const now = Date.now();
if (missingSinceRef.current == null) { if (missingSinceRef.current == null) {
missingSinceRef.current = now; missingSinceRef.current = now;
return; return;
} }
if (now - missingSinceRef.current >= 30_000) { if (now - missingSinceRef.current >= 30_000) {
setSelectedIcao24(null); const timer = setTimeout(() => setSelectedIcao24(null), 0);
missingSinceRef.current = null; missingSinceRef.current = null;
return () => clearTimeout(timer);
} }
}, [selectedIcao24, selectedFlight, flights]); }, [selectedIcao24, selectedFlight, displayFlights]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -252,19 +457,55 @@ function FlightTrackerInner() {
}; };
}, []); }, []);
const handleClick = useCallback((info: PickingInfo<FlightState> | null) => { const handleClick = useCallback(
if (info?.object) { (info: PickingInfo<FlightState> | null) => {
setSelectedIcao24((prev) => if (fpvIcao24) return;
prev === info.object!.icao24 ? null : info.object!.icao24, if (info?.object) {
); const icao24 = info.object.icao24.toLowerCase();
setSelectedIcao24((prev) => (prev === icao24 ? null : icao24));
} else {
setSelectedIcao24(null);
}
},
[fpvIcao24],
);
const handleDeselectFlight = useCallback(() => {
if (fpvIcao24) {
setSelectedIcao24(fpvIcao24);
setFpvIcao24(null);
} else { } else {
setSelectedIcao24(null); setSelectedIcao24(null);
} }
}, []); }, [fpvIcao24]);
const handleDeselectFlight = useCallback(() => { const handleToggleFpv = useCallback(
setSelectedIcao24(null); (icao24: string) => {
}, []); const targetIcao24 = icao24.toLowerCase();
const flight =
displayFlights.find((f) => f.icao24.toLowerCase() === targetIcao24) ??
flights.find((f) => f.icao24.toLowerCase() === targetIcao24);
if (!flight) return;
if (flight.longitude == null || flight.latitude == null) return;
if (flight.onGround) return;
setFpvSeedCenter({ lng: flight.longitude, lat: flight.latitude });
setFpvIcao24((prev) => {
if (prev === targetIcao24) {
setFpvSeedCenter(null);
setSelectedIcao24(targetIcao24);
return null;
}
return targetIcao24;
});
setFollowIcao24(null);
},
[displayFlights, flights],
);
const handleExitFpv = useCallback(() => {
setSelectedIcao24(fpvIcao24);
setFpvIcao24(null);
}, [fpvIcao24]);
const handleNorthUp = useCallback(() => { const handleNorthUp = useCallback(() => {
window.dispatchEvent(new CustomEvent("aeris:north-up")); window.dispatchEvent(new CustomEvent("aeris:north-up"));
@ -295,6 +536,79 @@ function FlightTrackerInner() {
setShowHelp((prev) => !prev); setShowHelp((prev) => !prev);
}, []); }, []);
const handleToggleFpvKey = useCallback(() => {
if (fpvIcao24) {
setSelectedIcao24(fpvIcao24);
setFpvIcao24(null);
} else if (selectedIcao24) {
handleToggleFpv(selectedIcao24);
}
}, [fpvIcao24, selectedIcao24, handleToggleFpv]);
const handleLookupFlight = useCallback(
async (rawQuery: string, enterFpv = false): Promise<boolean> => {
const compactQuery = rawQuery.trim().toLowerCase().replace(/\s+/g, "");
if (!compactQuery) return false;
const localMatch =
displayFlights.find((f) => f.icao24.toLowerCase() === compactQuery) ??
displayFlights.find((f) =>
formatCallsign(f.callsign)
.toLowerCase()
.replace(/\s+/g, "")
.includes(compactQuery),
) ??
null;
if (localMatch) {
setSelectedIcao24(localMatch.icao24);
setFollowIcao24(null);
if (
enterFpv &&
!localMatch.onGround &&
localMatch.longitude != null &&
localMatch.latitude != null
) {
setFpvSeedCenter({
lng: localMatch.longitude,
lat: localMatch.latitude,
});
setFpvIcao24(localMatch.icao24);
}
return true;
}
const result = ICAO24_REGEX.test(compactQuery)
? await fetchFlightByIcao24(compactQuery)
: await fetchFlightByCallsign(compactQuery);
if (!result.flight) return false;
const focusCity = cityFromFlight(result.flight);
if (focusCity) {
setCityOverride(focusCity);
syncCityToUrl(focusCity);
}
setSelectedIcao24(result.flight.icao24);
setFollowIcao24(null);
if (
enterFpv &&
!result.flight.onGround &&
result.flight.longitude != null &&
result.flight.latitude != null
) {
setFpvSeedCenter({
lng: result.flight.longitude,
lat: result.flight.latitude,
});
setFpvIcao24(result.flight.icao24);
}
return true;
},
[displayFlights],
);
useKeyboardShortcuts({ useKeyboardShortcuts({
onNorthUp: handleNorthUp, onNorthUp: handleNorthUp,
onResetView: handleResetView, onResetView: handleResetView,
@ -302,126 +616,163 @@ function FlightTrackerInner() {
onOpenSearch: handleOpenSearch, onOpenSearch: handleOpenSearch,
onToggleHelp: handleToggleHelp, onToggleHelp: handleToggleHelp,
onDeselect: handleDeselectFlight, onDeselect: handleDeselectFlight,
onToggleFpv: handleToggleFpvKey,
isFpv: fpvIcao24 !== null,
}); });
return ( return (
<main className="relative h-dvh w-screen overflow-hidden bg-black"> <main className="relative h-dvh w-screen overflow-hidden bg-black">
<Map mapStyle={mapStyle.style} isDark={mapStyle.dark}> <MapView mapStyle={mapStyle.style} isDark={mapStyle.dark}>
<CameraController city={activeCity} /> <CameraController
city={activeCity}
followFlight={followFlight}
fpvFlight={fpvFlightOrCached}
fpvPositionRef={fpvPositionRef}
/>
<AirportLayer <AirportLayer
activeCity={activeCity} activeCity={activeCity}
onSelectAirport={setActiveCity} onSelectAirport={setActiveCity}
isDark={mapStyle.dark} isDark={mapStyle.dark}
/> />
<FlightLayers <FlightLayers
flights={flights} flights={displayFlights}
trails={trails} trails={displayTrails}
onClick={handleClick} onClick={handleClick}
selectedIcao24={selectedIcao24} selectedIcao24={fpvIcao24 ?? selectedIcao24}
showTrails={settings.showTrails} showTrails={settings.showTrails}
trailThickness={settings.trailThickness} trailThickness={settings.trailThickness}
trailDistance={settings.trailDistance} trailDistance={settings.trailDistance}
showShadows={settings.showShadows} showShadows={settings.showShadows}
showAltitudeColors={settings.showAltitudeColors} showAltitudeColors={settings.showAltitudeColors}
fpvIcao24={fpvIcao24}
fpvPositionRef={fpvPositionRef}
/> />
</Map> </MapView>
<div <div
data-map-theme={mapStyle.dark ? "dark" : "light"} data-map-theme={mapStyle.dark ? "dark" : "light"}
className="pointer-events-none absolute inset-0 z-10" className="pointer-events-none absolute inset-0 z-10"
> >
<div className="pointer-events-auto absolute left-3 top-3 flex items-center gap-3 sm:left-4 sm:top-4"> {!fpvIcao24 && (
<Brand isDark={mapStyle.dark} /> <div className="pointer-events-auto absolute left-3 top-3 flex items-center gap-3 sm:left-4 sm:top-4">
</div> <Brand isDark={mapStyle.dark} />
</div>
)}
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16"> {!fpvIcao24 && (
<FlightCard flight={displayFlight} onClose={handleDeselectFlight} /> <div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
</div> <FlightCard
flight={displayFlight}
onClose={handleDeselectFlight}
onToggleFpv={handleToggleFpv}
isFpvActive={
fpvIcao24 !== null && fpvIcao24 === displayFlight?.icao24
}
/>
</div>
)}
<div className="pointer-events-auto absolute right-3 top-3 flex items-center gap-1.5 sm:right-4 sm:top-4 sm:gap-2"> {!fpvIcao24 && (
<motion.button <div className="pointer-events-auto absolute right-3 top-3 flex items-center gap-1.5 sm:right-4 sm:top-4 sm:gap-2">
onClick={handleToggleHelp} <motion.button
className="hidden h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors sm:flex" onClick={handleToggleHelp}
style={{ className="hidden h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors sm:flex"
borderWidth: 1, style={{
borderColor: "rgb(var(--ui-fg) / 0.06)", borderWidth: 1,
backgroundColor: "rgb(var(--ui-fg) / 0.03)", borderColor: "rgb(var(--ui-fg) / 0.06)",
color: "rgb(var(--ui-fg) / 0.5)", backgroundColor: "rgb(var(--ui-fg) / 0.03)",
}} color: "rgb(var(--ui-fg) / 0.5)",
whileHover={{ scale: 1.05 }} }}
whileTap={{ scale: 0.95 }} whileHover={{ scale: 1.05 }}
aria-label="Keyboard shortcuts" whileTap={{ scale: 0.95 }}
title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts"
> title="Keyboard shortcuts (?)"
<Keyboard className="h-4 w-4" /> >
</motion.button> <Keyboard className="h-4 w-4" />
<a </motion.button>
href={GITHUB_REPO_URL} <a
target="_blank" href={GITHUB_REPO_URL}
rel="noreferrer" target="_blank"
aria-label="Open GitHub repository" rel="noreferrer"
className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors" aria-label="Open GitHub repository"
style={{ className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
borderWidth: 1, style={{
borderColor: "rgb(var(--ui-fg) / 0.06)", borderWidth: 1,
backgroundColor: "rgb(var(--ui-fg) / 0.03)", borderColor: "rgb(var(--ui-fg) / 0.06)",
color: "rgb(var(--ui-fg) / 0.5)", backgroundColor: "rgb(var(--ui-fg) / 0.03)",
}} color: "rgb(var(--ui-fg) / 0.5)",
title={ }}
repoStars != null title={
? `GitHub · ${formatStarCount(repoStars)} stars` repoStars != null
: "Open GitHub repository" ? `GitHub · ${formatStarCount(repoStars)} stars`
} : "Open GitHub repository"
> }
<Github className="h-4 w-4" /> >
{repoStars != null && ( <Github className="h-4 w-4" />
<span {repoStars != null && (
className="pointer-events-none absolute -bottom-1 -right-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold tabular-nums" <span
style={{ className="pointer-events-none absolute -bottom-1 -right-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold tabular-nums"
backgroundColor: "rgb(var(--ui-bg) / 0.95)", style={{
border: "1px solid rgb(var(--ui-fg) / 0.1)", backgroundColor: "rgb(var(--ui-bg) / 0.95)",
color: "rgb(var(--ui-fg) / 0.55)", border: "1px solid rgb(var(--ui-fg) / 0.1)",
}} color: "rgb(var(--ui-fg) / 0.55)",
> }}
<span className="flex items-center gap-0.5"> >
<Star className="h-2 w-2" /> <span className="flex items-center gap-0.5">
{formatStarCount(repoStars)} <Star className="h-2 w-2" />
{formatStarCount(repoStars)}
</span>
</span> </span>
</span> )}
)} </a>
</a> <ControlPanel
<ControlPanel activeCity={activeCity}
activeCity={activeCity} onSelectCity={setActiveCity}
onSelectCity={setActiveCity} activeStyle={mapStyle}
activeStyle={mapStyle} onSelectStyle={setMapStyle}
onSelectStyle={setMapStyle} flights={displayFlights}
/> activeFlightIcao24={selectedIcao24}
</div> onLookupFlight={handleLookupFlight}
/>
</div>
)}
<div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] left-3 mb-3 sm:bottom-4 sm:left-4 sm:mb-0"> {!fpvIcao24 && (
<StatusBar <div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] left-3 mb-3 sm:bottom-4 sm:left-4 sm:mb-0">
flightCount={flights.length} <StatusBar
cityName={activeCity.name} flightCount={flights.length}
loading={loading} cityName={activeCity.name}
rateLimited={rateLimited} loading={loading}
retryIn={retryIn} rateLimited={rateLimited}
onNorthUp={handleNorthUp} retryIn={retryIn}
onResetView={handleResetView} onNorthUp={handleNorthUp}
onRandomAirport={handleRandomAirport} onResetView={handleResetView}
/> onRandomAirport={handleRandomAirport}
</div> />
</div>
)}
<div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 flex flex-col items-end gap-2 sm:bottom-4 sm:right-4 sm:mb-0"> {!fpvIcao24 && (
<CameraControls /> <div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 flex flex-col items-end gap-2 sm:bottom-4 sm:right-4 sm:mb-0">
<AltitudeLegend /> <CameraControls />
<MapAttribution styleId={mapStyle.id} /> <AltitudeLegend />
</div> <MapAttribution styleId={mapStyle.id} />
</div>
)}
</div> </div>
<KeyboardShortcutsHelp {!fpvIcao24 && (
open={showHelp} <KeyboardShortcutsHelp
onClose={() => setShowHelp(false)} open={showHelp}
/> onClose={() => setShowHelp(false)}
/>
)}
<AnimatePresence>
{fpvIcao24 && fpvFlightOrCached && (
<FpvHud flight={fpvFlightOrCached} onExit={handleExitFpv} />
)}
</AnimatePresence>
</main> </main>
); );
} }

View File

@ -0,0 +1,93 @@
import type maplibregl from "maplibre-gl";
import { MercatorCoordinate } from "maplibre-gl";
export const FPV_DISTANCE_ZOOM_OFFSET = 1.1;
export function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
export function smoothstep(t: number): number {
return t * t * (3 - 2 * t);
}
export function lerp(from: number, to: number, t: number): number {
return from + (to - from) * t;
}
export function normalizeLng(lng: number): number {
return ((lng + 540) % 360) - 180;
}
export function lerpLng(from: number, to: number, t: number): number {
const delta = ((to - from + 540) % 360) - 180;
return normalizeLng(from + delta * t);
}
export function fpvZoomForAltitude(altMeters: number): number {
if (!Number.isFinite(altMeters)) return 12;
const alt = Math.max(altMeters, 0);
if (alt < 50) return 16.2;
const zoom = 18.1 - 2.0 * Math.log10(Math.max(alt, 50));
return Math.max(10.1, Math.min(16.2, zoom));
}
export function projectLngLatElevationPixelDelta(
map: maplibregl.Map,
lng: number,
lat: number,
elevationMeters: number,
): { dx: number; dy: number } | null {
type Transform3DLike = {
_pixelMatrix3D?: unknown;
centerPoint?: { x: number; y: number };
coordinatePoint: (
coord: MercatorCoordinate,
elevation: number,
pixelMatrix3D: unknown,
) => { x: number; y: number } | null;
};
const tr = (map as unknown as { transform?: Transform3DLike }).transform;
if (!tr || typeof tr.coordinatePoint !== "function") return null;
const pixelMatrix3D = tr._pixelMatrix3D;
const centerPoint = tr.centerPoint;
if (!pixelMatrix3D || !centerPoint) return null;
let p: { x: number; y: number } | null = null;
try {
p = tr.coordinatePoint(
MercatorCoordinate.fromLngLat({ lng, lat }),
elevationMeters,
pixelMatrix3D,
);
} catch {
return null;
}
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return null;
return { dx: p.x - centerPoint.x, dy: p.y - centerPoint.y };
}
export function setMapInteractionsEnabled(
map: maplibregl.Map,
enabled: boolean,
): void {
if (enabled) {
map.dragPan.enable();
map.dragRotate.enable();
map.scrollZoom.enable();
map.touchZoomRotate.enable();
map.doubleClickZoom.enable();
map.keyboard.enable();
return;
}
map.dragPan.disable();
map.dragRotate.disable();
map.scrollZoom.disable();
map.touchZoomRotate.disable();
map.doubleClickZoom.disable();
map.keyboard.disable();
}

View File

@ -1,15 +1,37 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, type MutableRefObject } from "react";
import { useMap } from "./map"; import { useMap } from "./map";
import {
FPV_DISTANCE_ZOOM_OFFSET,
fpvZoomForAltitude,
lerp,
lerpLng,
normalizeLng,
projectLngLatElevationPixelDelta,
setMapInteractionsEnabled,
smoothstep,
} from "./camera-controller-utils";
import { useSettings } from "@/hooks/use-settings"; import { useSettings } from "@/hooks/use-settings";
import type { City } from "@/lib/cities"; import type { City } from "@/lib/cities";
import type { FlightState } from "@/lib/opensky";
const IDLE_TIMEOUT_MS = 5_000; const IDLE_TIMEOUT_MS = 5_000;
const ORBIT_EASE_IN_MS = 2000; const ORBIT_EASE_IN_MS = 2000;
const DEFAULT_ZOOM = 9.2; const DEFAULT_ZOOM = 9.2;
const DEFAULT_PITCH = 49; const DEFAULT_PITCH = 49;
const DEFAULT_BEARING = 27.4; const DEFAULT_BEARING = 27.4;
const FOLLOW_ZOOM = 10.5;
const FOLLOW_PITCH = 55;
const FOLLOW_EASE_MS = 1200;
const FPV_FLY_DURATION = 1600;
const FPV_PITCH = 65;
const FPV_CENTER_ALPHA = 0.16;
const FPV_BEARING_ALPHA = 0.1;
const FPV_ZOOM_ALPHA = 0.06;
const FPV_IDLE_RECENTER_MS = 1200;
const FPV_EASE_IN_MS = 600;
const CAMERA_ACCEL = 2.5; const CAMERA_ACCEL = 2.5;
const CAMERA_DECEL = 4.0; const CAMERA_DECEL = 4.0;
@ -26,13 +48,39 @@ type ActionState = {
impulseEnd: number; impulseEnd: number;
}; };
export function CameraController({ city }: { city: City }) { type FpvPosition = { lng: number; lat: number; alt: number; track: number };
export function CameraController({
city,
followFlight = null,
fpvFlight = null,
fpvPositionRef,
}: {
city: City;
followFlight?: FlightState | null;
fpvFlight?: FlightState | null;
fpvPositionRef?: MutableRefObject<FpvPosition | null>;
}) {
const { map, isLoaded } = useMap(); const { map, isLoaded } = useMap();
const { settings } = useSettings(); const { settings } = useSettings();
const prevCityRef = useRef<string | null>(null); const prevCityRef = useRef<string | null>(null);
const prevFollowRef = useRef<string | null>(null);
const prevFpvRef = useRef<string | null>(null);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const orbitFrameRef = useRef<number | null>(null); const orbitFrameRef = useRef<number | null>(null);
const isInteractingRef = useRef(false); const isInteractingRef = useRef(false);
const isFollowingRef = useRef(false);
const isFpvActiveRef = useRef(false);
const fpvFlightRef = useRef<FlightState | null>(fpvFlight);
const fpvPosRef = useRef(fpvPositionRef);
useEffect(() => {
fpvPosRef.current = fpvPositionRef;
}, [fpvPositionRef]);
useEffect(() => {
fpvFlightRef.current = fpvFlight;
}, [fpvFlight]);
useEffect(() => { useEffect(() => {
if (!map || !isLoaded || !city) return; if (!map || !isLoaded || !city) return;
@ -49,10 +97,300 @@ export function CameraController({ city }: { city: City }) {
}); });
}, [map, isLoaded, city]); }, [map, isLoaded, city]);
useEffect(() => {
if (!map || !isLoaded) return;
const followKey = followFlight?.icao24 ?? null;
if (followKey === prevFollowRef.current) return;
prevFollowRef.current = followKey;
if (
!followFlight ||
followFlight.longitude == null ||
followFlight.latitude == null
) {
isFollowingRef.current = false;
return;
}
isFollowingRef.current = true;
const bearing = Number.isFinite(followFlight.trueTrack)
? followFlight.trueTrack!
: map.getBearing();
map.flyTo({
center: [followFlight.longitude, followFlight.latitude],
zoom: FOLLOW_ZOOM,
pitch: FOLLOW_PITCH,
bearing,
duration: 2200,
essential: true,
});
}, [map, isLoaded, followFlight]);
useEffect(() => {
if (!map || !isLoaded || !followFlight) return;
if (followFlight.longitude == null || followFlight.latitude == null) return;
if (!isFollowingRef.current) return;
map.easeTo({
center: [followFlight.longitude, followFlight.latitude],
bearing: Number.isFinite(followFlight.trueTrack)
? followFlight.trueTrack!
: map.getBearing(),
duration: FOLLOW_EASE_MS,
essential: true,
});
}, [
map,
isLoaded,
followFlight,
followFlight?.longitude,
followFlight?.latitude,
followFlight?.trueTrack,
]);
useEffect(() => {
if (!map || !isLoaded) {
if (isFpvActiveRef.current) {
isFpvActiveRef.current = false;
}
return;
}
const fpv = fpvFlightRef.current;
const fpvKey = fpv?.icao24 ?? null;
if (fpvKey === prevFpvRef.current) return;
const wasFpv = prevFpvRef.current !== null;
prevFpvRef.current = fpvKey;
if (!fpv || fpv.longitude == null || fpv.latitude == null) {
isFpvActiveRef.current = false;
if (wasFpv) {
setMapInteractionsEnabled(map, true);
}
if (wasFpv) {
map.flyTo({
center: city.coordinates,
zoom: DEFAULT_ZOOM,
pitch: DEFAULT_PITCH,
bearing: DEFAULT_BEARING,
duration: 1800,
essential: true,
});
}
return;
}
isFpvActiveRef.current = true;
setMapInteractionsEnabled(map, true);
const bearing = Number.isFinite(fpv.trueTrack)
? fpv.trueTrack!
: map.getBearing();
const safeAltitude = Number.isFinite(fpv.baroAltitude)
? fpv.baroAltitude!
: 5000;
const zoom = fpvZoomForAltitude(safeAltitude) - FPV_DISTANCE_ZOOM_OFFSET;
let fpvOffsetX = 0;
let fpvOffsetY = 0;
map.flyTo({
center: [normalizeLng(fpv.longitude), fpv.latitude],
zoom,
pitch: FPV_PITCH,
bearing,
duration: FPV_FLY_DURATION,
essential: true,
});
let frameId: number | null = null;
let startupTimer: ReturnType<typeof setTimeout> | null = null;
let prevBearing = bearing;
let lastInteractionTime = 0; // 0 = no interaction yet → track immediately
let recenterStartTime = 0;
let programmaticMove = false;
function onUserInteraction() {
if (programmaticMove) return;
lastInteractionTime = performance.now();
recenterStartTime = 0;
}
const onMapInteraction = (e: unknown) => {
if (programmaticMove) return;
const evt = e as { originalEvent?: Event };
if (!evt?.originalEvent) return;
onUserInteraction();
};
const interactionEventTypes = [
"movestart",
"move",
"zoomstart",
"zoom",
"rotatestart",
"rotate",
"pitchstart",
"pitch",
] as const;
for (const t of interactionEventTypes) {
map.on(t, onMapInteraction);
}
function keepInFrame() {
if (!isFpvActiveRef.current || !map) {
frameId = null;
return;
}
const interpPos = fpvPosRef.current?.current ?? null;
const live = fpvFlightRef.current;
const posLng = interpPos?.lng ?? live?.longitude ?? null;
const posLat = interpPos?.lat ?? live?.latitude ?? null;
const posAlt = interpPos?.alt ?? live?.baroAltitude ?? 5000;
const posTrack = interpPos?.track ?? live?.trueTrack ?? null;
if (posLng == null || posLat == null) {
frameId = requestAnimationFrame(keepInFrame);
return;
}
if (
!Number.isFinite(posLng) ||
!Number.isFinite(posLat) ||
Math.abs(posLat) > 90
) {
frameId = requestAnimationFrame(keepInFrame);
return;
}
const now = performance.now();
const idleMs =
lastInteractionTime === 0
? FPV_IDLE_RECENTER_MS + 1
: now - lastInteractionTime;
const isIdle = idleMs > FPV_IDLE_RECENTER_MS;
let trackingStrength = 0;
if (isIdle) {
if (recenterStartTime === 0) {
recenterStartTime = now;
}
const easeElapsed = now - recenterStartTime;
const t = Math.min(easeElapsed / FPV_EASE_IN_MS, 1);
trackingStrength = smoothstep(t);
}
const liveBearing =
posTrack !== null && Number.isFinite(posTrack) ? posTrack : prevBearing;
const bearingDelta = ((liveBearing - prevBearing + 540) % 360) - 180;
prevBearing = prevBearing + bearingDelta * FPV_BEARING_ALPHA;
if (trackingStrength > 0.001) {
const safeAlt = Number.isFinite(posAlt) ? posAlt : 5000;
const targetZoom =
fpvZoomForAltitude(safeAlt) - FPV_DISTANCE_ZOOM_OFFSET;
const currentZoom = map.getZoom();
const zoomAlpha = FPV_ZOOM_ALPHA * trackingStrength;
const smoothZoom = lerp(currentZoom, targetZoom, zoomAlpha);
const currentPitch = map.getPitch();
const targetLng = normalizeLng(posLng);
const targetLat = posLat;
const center = map.getCenter();
const centerAlpha = FPV_CENTER_ALPHA * trackingStrength;
const canvas = map.getCanvas();
const canvasW = Math.max(1, canvas.clientWidth);
const canvasH = Math.max(1, canvas.clientHeight);
const elevationMeters = Math.max(safeAlt * 5, 200);
const deltaPx = projectLngLatElevationPixelDelta(
map,
targetLng,
targetLat,
elevationMeters,
);
if (deltaPx) {
const desiredX = fpvOffsetX - deltaPx.dx;
const desiredY = fpvOffsetY - deltaPx.dy;
const offsetAlpha = 0.08 * trackingStrength;
fpvOffsetX = lerp(fpvOffsetX, desiredX, offsetAlpha);
fpvOffsetY = lerp(fpvOffsetY, desiredY, offsetAlpha);
} else {
const decayAlpha = 0.1 * trackingStrength;
fpvOffsetX = lerp(fpvOffsetX, 0, decayAlpha);
fpvOffsetY = lerp(fpvOffsetY, 0, decayAlpha);
}
const maxScale = Math.min(1.5, Math.max(1, elevationMeters / 15_000));
const maxOffset = 0.45 * maxScale * Math.min(canvasW, canvasH);
fpvOffsetX = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetX));
fpvOffsetY = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetY));
const currentBearing = map.getBearing();
const bearingToCurrent =
((prevBearing - currentBearing + 540) % 360) - 180;
const newMapBearing =
currentBearing +
bearingToCurrent * FPV_BEARING_ALPHA * trackingStrength;
const pitchAlpha = 0.05 * trackingStrength;
const newPitch = lerp(currentPitch, FPV_PITCH, pitchAlpha);
programmaticMove = true;
try {
map.easeTo({
center: [
lerpLng(center.lng, targetLng, centerAlpha),
lerp(center.lat, targetLat, centerAlpha),
],
bearing: newMapBearing,
zoom: smoothZoom,
pitch: newPitch,
offset: [fpvOffsetX, fpvOffsetY],
duration: 0,
animate: false,
essential: true,
});
} finally {
programmaticMove = false;
}
}
frameId = requestAnimationFrame(keepInFrame);
}
startupTimer = setTimeout(() => {
startupTimer = null;
frameId = requestAnimationFrame(keepInFrame);
}, FPV_FLY_DURATION + 300);
return () => {
if (startupTimer) clearTimeout(startupTimer);
if (frameId != null) cancelAnimationFrame(frameId);
for (const t of interactionEventTypes) {
map.off(t, onMapInteraction);
}
if (map && isFpvActiveRef.current) {
setMapInteractionsEnabled(map, true);
isFpvActiveRef.current = false;
}
};
}, [map, isLoaded, fpvFlight?.icao24, city]);
useEffect(() => { useEffect(() => {
if (!map || !isLoaded || !city) return; if (!map || !isLoaded || !city) return;
const onNorthUp = () => { const onNorthUp = () => {
if (isFpvActiveRef.current) return;
map.easeTo({ map.easeTo({
bearing: 0, bearing: 0,
duration: 650, duration: 650,
@ -61,6 +399,7 @@ export function CameraController({ city }: { city: City }) {
}; };
const onResetView = (event: Event) => { const onResetView = (event: Event) => {
if (isFpvActiveRef.current) return;
const customEvent = event as CustomEvent<{ center?: [number, number] }>; const customEvent = event as CustomEvent<{ center?: [number, number] }>;
const center = customEvent.detail?.center ?? city.coordinates; const center = customEvent.detail?.center ?? city.coordinates;
map.flyTo({ map.flyTo({
@ -167,6 +506,7 @@ export function CameraController({ city }: { city: City }) {
} }
const onStart = (e: Event) => { const onStart = (e: Event) => {
if (isFpvActiveRef.current) return;
const { type, direction } = (e as CustomEvent).detail as { const { type, direction } = (e as CustomEvent).detail as {
type: CameraActionType; type: CameraActionType;
direction: number; direction: number;
@ -201,7 +541,14 @@ export function CameraController({ city }: { city: City }) {
}, [map, isLoaded]); }, [map, isLoaded]);
useEffect(() => { useEffect(() => {
if (!map || !isLoaded || !city || !settings.autoOrbit) { if (
!map ||
!isLoaded ||
!city ||
!settings.autoOrbit ||
followFlight ||
fpvFlight
) {
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current); if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
return; return;
@ -224,7 +571,7 @@ export function CameraController({ city }: { city: City }) {
if (!map || isInteractingRef.current) return; if (!map || isInteractingRef.current) return;
const resumeElapsed = performance.now() - resumeStart; const resumeElapsed = performance.now() - resumeStart;
const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1); const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1);
const easeFactor = t * t * (3 - 2 * t); const easeFactor = smoothstep(t);
const bearing = map.getBearing() + speed * easeFactor; const bearing = map.getBearing() + speed * easeFactor;
map.setBearing(bearing % 360); map.setBearing(bearing % 360);
orbitFrameRef.current = requestAnimationFrame(tick); orbitFrameRef.current = requestAnimationFrame(tick);
@ -290,6 +637,8 @@ export function CameraController({ city }: { city: City }) {
map, map,
isLoaded, isLoaded,
city, city,
followFlight,
fpvFlight,
settings.autoOrbit, settings.autoOrbit,
settings.orbitSpeed, settings.orbitSpeed,
settings.orbitDirection, settings.orbitDirection,

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, type MutableRefObject } from "react";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { MapboxOverlay } from "@deck.gl/mapbox"; import { MapboxOverlay } from "@deck.gl/mapbox";
import { IconLayer, PathLayer } from "@deck.gl/layers"; import { IconLayer, PathLayer } from "@deck.gl/layers";
@ -11,7 +11,6 @@ import type { FlightState } from "@/lib/opensky";
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";
/** Typed overlay with deck.gl's pickObject capability */
type DeckGLOverlay = MapboxOverlay & { type DeckGLOverlay = MapboxOverlay & {
pickObject?(opts: { pickObject?(opts: {
x: number; x: number;
@ -192,8 +191,9 @@ function getRingUrl(): string {
function buildStartupFallbackTrail(f: FlightState): [number, number][] { function buildStartupFallbackTrail(f: FlightState): [number, number][] {
if (f.longitude == null || f.latitude == null) return []; if (f.longitude == null || f.latitude == null) return [];
const heading = ((f.trueTrack ?? 0) * Math.PI) / 180; const heading =
const speed = f.velocity ?? 200; ((Number.isFinite(f.trueTrack) ? f.trueTrack! : 0) * Math.PI) / 180;
const speed = Number.isFinite(f.velocity) ? f.velocity! : 200;
const degPerSecond = speed / 111_320; const degPerSecond = speed / 111_320;
const path: [number, number][] = []; const path: [number, number][] = [];
@ -448,6 +448,13 @@ type FlightLayerProps = {
trailDistance: number; trailDistance: number;
showShadows: boolean; showShadows: boolean;
showAltitudeColors: boolean; showAltitudeColors: boolean;
fpvIcao24?: string | null;
fpvPositionRef?: MutableRefObject<{
lng: number;
lat: number;
alt: number;
track: number;
} | null>;
}; };
export function FlightLayers({ export function FlightLayers({
@ -460,6 +467,8 @@ export function FlightLayers({
trailDistance, trailDistance,
showShadows, showShadows,
showAltitudeColors, showAltitudeColors,
fpvIcao24 = null,
fpvPositionRef,
}: FlightLayerProps) { }: FlightLayerProps) {
const { map, isLoaded } = useMap(); const { map, isLoaded } = useMap();
const overlayRef = useRef<MapboxOverlay | null>(null); const overlayRef = useRef<MapboxOverlay | null>(null);
@ -481,6 +490,8 @@ export function FlightLayers({
const showShadowsRef = useRef(showShadows); const showShadowsRef = useRef(showShadows);
const showAltColorsRef = useRef(showAltitudeColors); const showAltColorsRef = useRef(showAltitudeColors);
const selectedIcao24Ref = useRef(selectedIcao24); const selectedIcao24Ref = useRef(selectedIcao24);
const fpvIcao24Ref = useRef(fpvIcao24);
const fpvPosRef = useRef(fpvPositionRef);
const prevSelectedRef = useRef<string | null>(null); const prevSelectedRef = useRef<string | null>(null);
const selectionChangeTimeRef = useRef(0); const selectionChangeTimeRef = useRef(0);
const SELECTION_FADE_MS = 600; const SELECTION_FADE_MS = 600;
@ -493,6 +504,8 @@ export function FlightLayers({
trailDistanceRef.current = trailDistance; trailDistanceRef.current = trailDistance;
showShadowsRef.current = showShadows; showShadowsRef.current = showShadows;
showAltColorsRef.current = showAltitudeColors; showAltColorsRef.current = showAltitudeColors;
fpvIcao24Ref.current = fpvIcao24;
fpvPosRef.current = fpvPositionRef;
if (selectedIcao24 !== selectedIcao24Ref.current) { if (selectedIcao24 !== selectedIcao24Ref.current) {
prevSelectedRef.current = selectedIcao24Ref.current; prevSelectedRef.current = selectedIcao24Ref.current;
selectionChangeTimeRef.current = performance.now(); selectionChangeTimeRef.current = performance.now();
@ -507,6 +520,8 @@ export function FlightLayers({
showShadows, showShadows,
showAltitudeColors, showAltitudeColors,
selectedIcao24, selectedIcao24,
fpvIcao24,
fpvPositionRef,
]); ]);
useEffect(() => { useEffect(() => {
@ -544,11 +559,12 @@ export function FlightLayers({
for (const f of flights) { for (const f of flights) {
if (f.longitude != null && f.latitude != null) { if (f.longitude != null && f.latitude != null) {
const prev = newPrev.get(f.icao24); const prev = newPrev.get(f.icao24);
const rawTrack = f.trueTrack ?? 0; const rawTrack = Number.isFinite(f.trueTrack) ? f.trueTrack! : 0;
const rawAlt = Number.isFinite(f.baroAltitude) ? f.baroAltitude! : 0;
next.set(f.icao24, { next.set(f.icao24, {
lng: f.longitude, lng: f.longitude,
lat: f.latitude, lat: f.latitude,
alt: f.baroAltitude ?? 0, alt: rawAlt,
track: track:
prev != null prev != null
? lerpAngle(prev.track, rawTrack, TRACK_DAMPING) ? lerpAngle(prev.track, rawTrack, TRACK_DAMPING)
@ -576,7 +592,6 @@ export function FlightLayers({
[map], [map],
); );
// Reset cursor if component unmounts while hovering.
useEffect(() => { useEffect(() => {
return () => { return () => {
const canvas = map?.getCanvas(); const canvas = map?.getCanvas();
@ -674,7 +689,7 @@ export function FlightLayers({
let prev = prevSnapshotsRef.current.get(f.icao24); let prev = prevSnapshotsRef.current.get(f.icao24);
if (!prev) { if (!prev) {
const rad = (curr.track * Math.PI) / 180; const rad = (curr.track * Math.PI) / 180;
const spd = f.velocity ?? 200; const spd = Number.isFinite(f.velocity) ? f.velocity! : 200;
const step = Math.min( const step = Math.min(
(spd * (animDurationRef.current / 1000)) / 111_320, (spd * (animDurationRef.current / 1000)) / 111_320,
0.015, 0.015,
@ -705,7 +720,7 @@ export function FlightLayers({
} }
const heading = (curr.track * Math.PI) / 180; const heading = (curr.track * Math.PI) / 180;
const speed = f.velocity ?? 200; const speed = Number.isFinite(f.velocity) ? f.velocity! : 200;
const extraSec = ((rawT - 1) * animDurationRef.current) / 1000; const extraSec = ((rawT - 1) * animDurationRef.current) / 1000;
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03); const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
const moveDx = Math.sin(heading) * extraDeg; const moveDx = Math.sin(heading) * extraDeg;
@ -724,6 +739,33 @@ export function FlightLayers({
interpolatedMap.set(f.icao24, f); interpolatedMap.set(f.icao24, f);
} }
const fpvId = fpvIcao24Ref.current?.toLowerCase() ?? null;
const visibleFlights = interpolated;
const fpvPosOut = fpvPosRef.current;
if (fpvPosOut && fpvId) {
const fpvF =
interpolated.find((f) => f.icao24.toLowerCase() === fpvId) ?? null;
if (
fpvF &&
Number.isFinite(fpvF.longitude) &&
Number.isFinite(fpvF.latitude)
) {
fpvPosOut.current = {
lng: fpvF.longitude!,
lat: fpvF.latitude!,
alt: Number.isFinite(fpvF.baroAltitude)
? fpvF.baroAltitude!
: 5000,
track: Number.isFinite(fpvF.trueTrack) ? fpvF.trueTrack! : 0,
};
} else {
fpvPosOut.current = null;
}
} else if (fpvPosOut && !fpvId) {
fpvPosOut.current = null;
}
const pitchByIcao = new Map<string, number>(); const pitchByIcao = new Map<string, number>();
for (const f of interpolated) { for (const f of interpolated) {
const curr = currSnapshotsRef.current.get(f.icao24); const curr = currSnapshotsRef.current.get(f.icao24);
@ -771,8 +813,10 @@ export function FlightLayers({
})() })()
: 0; : 0;
const speed = f.velocity ?? 0; const speed = Number.isFinite(f.velocity) ? f.velocity! : 0;
const verticalRate = f.verticalRate ?? 0; const verticalRate = Number.isFinite(f.verticalRate)
? f.verticalRate!
: 0;
const kinematicPitch = const kinematicPitch =
speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0; speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0;
@ -789,12 +833,13 @@ export function FlightLayers({
layers.push( layers.push(
new IconLayer<FlightState>({ new IconLayer<FlightState>({
id: "flight-shadows", id: "flight-shadows",
data: interpolated, data: visibleFlights,
getPosition: (d) => [d.longitude!, d.latitude!, 0], getPosition: (d) => [d.longitude!, d.latitude!, 0],
getIcon: () => "aircraft", getIcon: () => "aircraft",
getSize: (d) => 20 * categorySizeMultiplier(d.category), getSize: (d) => 20 * categorySizeMultiplier(d.category),
getColor: [0, 0, 0, 60], getColor: () => [0, 0, 0, 60],
getAngle: (d) => 360 - (d.trueTrack ?? 0), getAngle: (d) =>
360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0),
iconAtlas: atlasUrl, iconAtlas: atlasUrl,
iconMapping: AIRCRAFT_ICON_MAPPING, iconMapping: AIRCRAFT_ICON_MAPPING,
billboard: false, billboard: false,
@ -942,6 +987,7 @@ export function FlightLayers({
const animFlight = interpolatedMap.get(d.icao24); const animFlight = interpolatedMap.get(d.icao24);
const visiblePoints = getVisibleTrailPoints(d, animFlight); const visiblePoints = getVisibleTrailPoints(d, animFlight);
const len = visiblePoints.length; const len = visiblePoints.length;
return visiblePoints.map((point, i) => { return visiblePoints.map((point, i) => {
const tVal = len > 1 ? i / (len - 1) : 1; const tVal = len > 1 ? i / (len - 1) : 1;
const fade = Math.pow(tVal, 1.65); const fade = Math.pow(tVal, 1.65);
@ -1058,7 +1104,7 @@ export function FlightLayers({
layers.push( layers.push(
new ScenegraphLayer<FlightState>({ new ScenegraphLayer<FlightState>({
id: "flight-aircraft", id: "flight-aircraft",
data: interpolated, data: visibleFlights,
getPosition: (d) => [ getPosition: (d) => [
d.longitude!, d.longitude!,
d.latitude!, d.latitude!,
@ -1066,7 +1112,7 @@ export function FlightLayers({
], ],
getOrientation: (d) => { getOrientation: (d) => {
const pitch = pitchByIcao.get(d.icao24) ?? 0; const pitch = pitchByIcao.get(d.icao24) ?? 0;
const yaw = -(d.trueTrack ?? 0); const yaw = -(Number.isFinite(d.trueTrack) ? d.trueTrack! : 0);
return [pitch, yaw, 90]; return [pitch, yaw, 90];
}, },
getColor: (d) => { getColor: (d) => {

View File

@ -0,0 +1,257 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Search, X, MapPin, ChevronRight } from "lucide-react";
import { CITIES, type City } from "@/lib/cities";
import { searchAirports, type Airport } from "@/lib/airports";
import { ScrollArea } from "@/components/ui/scroll-area";
type AirportSearchInputProps = {
placeholder?: string;
selected: Airport | null;
onSelect: (airport: Airport) => void;
onClear?: () => void;
autoFocus?: boolean;
label?: string;
};
export function AirportSearchInput({
placeholder = "Search airports...",
selected,
onSelect,
onClear,
autoFocus = false,
label = "Search airports",
}: AirportSearchInputProps) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (autoFocus) {
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [autoFocus]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const { featured, airports } = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) {
return {
featured: CITIES.slice(0, 10),
airports: [] as ReturnType<typeof searchAirports>,
};
}
const featured = CITIES.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.iata.toLowerCase().includes(q) ||
c.country.toLowerCase().includes(q),
);
const featuredIatas = new Set(CITIES.map((c) => c.iata));
const airports = searchAirports(q, 15).filter(
(a) => !featuredIatas.has(a.iata),
);
return { featured, airports };
}, [query]);
const hasResults = featured.length > 0 || airports.length > 0;
function handleSelect(airport: Airport) {
onSelect(airport);
setQuery("");
setIsOpen(false);
}
function handleSelectCity(city: City) {
const real = searchAirports(city.iata, 1).find((a) => a.iata === city.iata);
const airport: Airport = real ?? {
iata: city.iata,
name: city.name,
city: city.name,
country: city.country,
lat: city.coordinates[1],
lng: city.coordinates[0],
};
handleSelect(airport);
}
function handleClear() {
setQuery("");
onClear?.();
inputRef.current?.focus();
}
return (
<div ref={containerRef} className="relative">
{selected && !isOpen ? (
<button
onClick={() => {
setIsOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="flex w-full items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2.5 text-left transition-colors hover:bg-white/6"
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-white/8">
<MapPin className="h-3 w-3 text-white/50" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[13px] font-semibold text-white/80">
{selected.iata}
</span>
<span className="ml-1.5 text-[11px] text-white/30">
{selected.city}
</span>
</div>
{onClear && (
<button
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
aria-label="Clear selection"
>
<X className="h-3 w-3" />
</button>
)}
</button>
) : (
<div className="flex items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-white/25" />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
aria-label={label}
className="flex-1 bg-transparent text-[13px] font-medium text-white/90 placeholder:text-white/20 outline-none"
/>
{query && (
<button
onClick={() => setQuery("")}
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
)}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.15 }}
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-white/8 bg-[#0c0c0e]/95 shadow-[0_20px_60px_rgba(0,0,0,.7)] backdrop-blur-2xl"
>
<ScrollArea className="max-h-56">
<div className="p-1.5">
{!hasResults && (
<p className="py-6 text-center text-[11px] text-white/25">
No airports found
</p>
)}
{featured.length > 0 && (
<>
{query && (
<p className="px-2.5 pt-1.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-white/15">
Featured
</p>
)}
{featured.map((city) => (
<DropdownRow
key={city.id}
name={city.name}
detail={`${city.iata} · ${city.country}`}
isActive={selected?.iata === city.iata}
onClick={() => handleSelectCity(city)}
/>
))}
</>
)}
{airports.length > 0 && (
<>
<p
className={`px-2.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-white/15 ${
featured.length > 0 ? "pt-2" : "pt-1.5"
}`}
>
Airports
</p>
{airports.map((airport) => (
<DropdownRow
key={airport.iata}
name={airport.name}
detail={`${airport.iata} · ${airport.city}, ${airport.country}`}
isActive={selected?.iata === airport.iata}
onClick={() => handleSelect(airport)}
/>
))}
</>
)}
</div>
</ScrollArea>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function DropdownRow({
name,
detail,
isActive,
onClick,
}: {
name: string;
detail: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`group flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-white/5 ${
isActive ? "bg-white/6" : ""
}`}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-white/4">
<MapPin className="h-3 w-3 text-white/35" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-[12px] font-medium text-white/75">{name}</p>
<p className="text-[10px] text-white/25">{detail}</p>
</div>
<ChevronRight className="h-3 w-3 shrink-0 text-white/10 group-hover:text-white/20" />
</button>
);
}

View File

@ -17,6 +17,9 @@ import {
Palette, Palette,
ArrowLeftRight, ArrowLeftRight,
Github, Github,
Plane,
Eye,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { CITIES, type City } from "@/lib/cities"; import { CITIES, type City } from "@/lib/cities";
import { searchAirports, airportToCity } from "@/lib/airports"; import { searchAirports, airportToCity } from "@/lib/airports";
@ -24,6 +27,8 @@ import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
import { useSettings, type OrbitDirection } from "@/hooks/use-settings"; import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import type { FlightState } from "@/lib/opensky";
import { formatCallsign } from "@/lib/flight-utils";
type TabId = "search" | "style" | "settings"; type TabId = "search" | "style" | "settings";
@ -38,6 +43,9 @@ type ControlPanelProps = {
onSelectCity: (city: City) => void; onSelectCity: (city: City) => void;
activeStyle: MapStyle; activeStyle: MapStyle;
onSelectStyle: (style: MapStyle) => void; onSelectStyle: (style: MapStyle) => void;
flights: FlightState[];
activeFlightIcao24: string | null;
onLookupFlight: (query: string, enterFpv?: boolean) => Promise<boolean>;
}; };
export function ControlPanel({ export function ControlPanel({
@ -45,6 +53,9 @@ export function ControlPanel({
onSelectCity, onSelectCity,
activeStyle, activeStyle,
onSelectStyle, onSelectStyle,
flights,
activeFlightIcao24,
onLookupFlight,
}: ControlPanelProps) { }: ControlPanelProps) {
const [openTab, setOpenTab] = useState<TabId | null>(null); const [openTab, setOpenTab] = useState<TabId | null>(null);
@ -94,6 +105,9 @@ export function ControlPanel({
}} }}
activeStyle={activeStyle} activeStyle={activeStyle}
onSelectStyle={onSelectStyle} onSelectStyle={onSelectStyle}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={onLookupFlight}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
@ -109,6 +123,9 @@ function PanelDialog({
onSelectCity, onSelectCity,
activeStyle, activeStyle,
onSelectStyle, onSelectStyle,
flights,
activeFlightIcao24,
onLookupFlight,
}: { }: {
activeTab: TabId; activeTab: TabId;
onTabChange: (tab: TabId) => void; onTabChange: (tab: TabId) => void;
@ -117,6 +134,9 @@ function PanelDialog({
onSelectCity: (city: City) => void; onSelectCity: (city: City) => void;
activeStyle: MapStyle; activeStyle: MapStyle;
onSelectStyle: (style: MapStyle) => void; onSelectStyle: (style: MapStyle) => void;
flights: FlightState[];
activeFlightIcao24: string | null;
onLookupFlight: (query: string, enterFpv?: boolean) => Promise<boolean>;
}) { }) {
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
@ -246,7 +266,7 @@ function PanelDialog({
</a> </a>
<div className="border-t border-white/3 pt-2 px-2.5"> <div className="border-t border-white/3 pt-2 px-2.5">
<p className="text-[10px] font-medium text-white/10 tracking-wide"> <p className="text-[10px] font-medium text-white/10 tracking-wide">
v0.1 \u00b7 OpenSky Network v0.1 · OpenSky Network
</p> </p>
</div> </div>
</div> </div>
@ -288,6 +308,13 @@ function PanelDialog({
<SearchContent <SearchContent
activeCity={activeCity} activeCity={activeCity}
onSelect={onSelectCity} onSelect={onSelectCity}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={async (query, enterFpv = false) => {
const found = await onLookupFlight(query, enterFpv);
if (found) onClose();
return found;
}}
/> />
</TabContent> </TabContent>
)} )}
@ -374,11 +401,19 @@ function TabContent({ children }: { children: ReactNode }) {
function SearchContent({ function SearchContent({
activeCity, activeCity,
onSelect, onSelect,
flights,
activeFlightIcao24,
onLookupFlight,
}: { }: {
activeCity: City; activeCity: City;
onSelect: (city: City) => void; onSelect: (city: City) => void;
flights: FlightState[];
activeFlightIcao24: string | null;
onLookupFlight: (query: string, enterFpv?: boolean) => Promise<boolean>;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [lookupBusy, setLookupBusy] = useState(false);
const [lookupError, setLookupError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@ -409,7 +444,58 @@ function SearchContent({
return { featured, airports }; return { featured, airports };
}, [query]); }, [query]);
const hasResults = featured.length > 0 || airports.length > 0; const normalizedQuery = query.trim().toLowerCase();
const compactQuery = normalizedQuery.replace(/\s+/g, "");
const isIcao24Query = /^[0-9a-f]{6}$/.test(compactQuery);
const flightMatches = useMemo(() => {
if (!compactQuery) return [] as FlightState[];
return flights
.filter((flight) => {
const icao = flight.icao24.toLowerCase();
const callsign = (flight.callsign ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, "");
return icao.includes(compactQuery) || callsign.includes(compactQuery);
})
.slice(0, 12);
}, [flights, compactQuery]);
const hasResults =
featured.length > 0 || airports.length > 0 || flightMatches.length > 0;
async function runLookup(enterFpv = false) {
if (!query.trim() || lookupBusy) return;
setLookupBusy(true);
setLookupError(null);
try {
const found = await onLookupFlight(query, enterFpv);
if (!found) {
setLookupError(
isIcao24Query
? "Flight not found for this ICAO24 right now"
: "No live worldwide flight match found (or rate-limited)",
);
}
} finally {
setLookupBusy(false);
}
}
async function openFlight(icao24: string, enterFpv = false) {
if (lookupBusy) return;
setLookupBusy(true);
setLookupError(null);
try {
const found = await onLookupFlight(icao24, enterFpv);
if (!found) {
setLookupError("Unable to open the selected flight");
}
} finally {
setLookupBusy(false);
}
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -418,9 +504,18 @@ function SearchContent({
<input <input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
placeholder="Search airports..." setQuery(e.target.value);
aria-label="Search airports by name, IATA code, city, or country" setLookupError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void runLookup(false);
}
}}
placeholder="Search airports or flight number (callsign/ICAO24)..."
aria-label="Search airports by name, IATA code, city, country, or flight callsign/ICAO24"
className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none" className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none"
/> />
{query && ( {query && (
@ -436,9 +531,64 @@ function SearchContent({
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2"> <div className="p-2">
{compactQuery && (
<div className="px-3 pb-2 space-y-2">
<button
type="button"
onClick={() => void runLookup(false)}
disabled={lookupBusy}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/4 px-3 py-2 text-[12px] font-medium text-white/75 transition-colors hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-60"
>
{lookupBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Search className="h-3.5 w-3.5" />
)}
<span>Open Flight Details</span>
</button>
<button
type="button"
onClick={() => void runLookup(true)}
disabled={lookupBusy}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-sky-400/25 bg-sky-500/10 px-3 py-2 text-[12px] font-medium text-sky-300/90 transition-colors hover:bg-sky-500/15 disabled:cursor-not-allowed disabled:opacity-60"
>
{lookupBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
<span>Open in FPV</span>
</button>
</div>
)}
{lookupError && (
<p className="px-3 pb-2 text-[11px] font-medium text-amber-300/85">
{lookupError}
</p>
)}
{flightMatches.length > 0 && (
<>
<p className="px-3 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/15">
Flights
</p>
{flightMatches.map((flight) => (
<FlightRow
key={flight.icao24}
callsign={formatCallsign(flight.callsign)}
detail={`${flight.icao24.toUpperCase()} · ${flight.originCountry}`}
isActive={activeFlightIcao24 === flight.icao24}
onOpen={() => void openFlight(flight.icao24, false)}
onFpv={() => void openFlight(flight.icao24, true)}
/>
))}
</>
)}
{!hasResults && ( {!hasResults && (
<p className="py-8 text-center text-[12px] text-white/25"> <p className="py-8 text-center text-[12px] text-white/25">
No airports found No airports or flights found
</p> </p>
)} )}
@ -453,7 +603,7 @@ function SearchContent({
<LocationRow <LocationRow
key={city.id} key={city.id}
name={city.name} name={city.name}
detail={`${city.iata} \u00b7 ${city.country}`} detail={`${city.iata} · ${city.country}`}
isActive={activeCity?.id === city.id} isActive={activeCity?.id === city.id}
onClick={() => onSelect(city)} onClick={() => onSelect(city)}
/> />
@ -474,7 +624,7 @@ function SearchContent({
<LocationRow <LocationRow
key={airport.iata} key={airport.iata}
name={airport.name} name={airport.name}
detail={`${airport.iata} \u00b7 ${airport.city}, ${airport.country}`} detail={`${airport.iata} · ${airport.city}, ${airport.country}`}
isActive={activeCity?.iata === airport.iata} isActive={activeCity?.iata === airport.iata}
onClick={() => onSelect(airportToCity(airport))} onClick={() => onSelect(airportToCity(airport))}
/> />
@ -524,6 +674,52 @@ function LocationRow({
); );
} }
function FlightRow({
callsign,
detail,
isActive,
onOpen,
onFpv,
}: {
callsign: string;
detail: string;
isActive: boolean;
onOpen: () => void;
onFpv: () => void;
}) {
return (
<div
className={`group flex items-center gap-2.5 rounded-xl px-3 py-2.5 transition-colors hover:bg-white/4 ${
isActive ? "bg-white/6" : ""
}`}
>
<button
onClick={onOpen}
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/4">
<Plane className="h-3.5 w-3.5 text-white/40" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-medium text-white/80">
{callsign}
</p>
<p className="text-[11px] font-medium text-white/25">{detail}</p>
</div>
</button>
<button
type="button"
onClick={onFpv}
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-sky-400/20 bg-sky-500/10 px-2 text-[10px] font-semibold uppercase tracking-wide text-sky-300/90 transition-colors hover:bg-sky-500/20"
aria-label="Open flight in FPV"
>
<Eye className="h-3 w-3" />
FPV
</button>
</div>
);
}
function StyleContent({ function StyleContent({
activeStyle, activeStyle,
onSelect, onSelect,
@ -546,8 +742,7 @@ function StyleContent({
</div> </div>
<div className="border-t border-white/4 px-5 py-3"> <div className="border-t border-white/4 px-5 py-3">
<p className="text-[11px] font-medium text-white/12"> <p className="text-[11px] font-medium text-white/12">
Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base Satellite © Esri · Terrain © OpenTopoMap · Base maps © CARTO
maps \u00a9 CARTO
</p> </p>
</div> </div>
</ScrollArea> </ScrollArea>
@ -594,6 +789,7 @@ function StyleTile({
fill fill
unoptimized unoptimized
onLoad={() => setImgLoaded(true)} onLoad={() => setImgLoaded(true)}
onError={() => setImgLoaded(true)}
className={`object-cover transition-all duration-500 group-hover:scale-105 ${ className={`object-cover transition-all duration-500 group-hover:scale-105 ${
imgLoaded ? "opacity-100" : "opacity-0" imgLoaded ? "opacity-100" : "opacity-0"
}`} }`}
@ -747,7 +943,7 @@ function OrbitSpeedSlider({
const activeLabel = const activeLabel =
ORBIT_SPEED_PRESETS.find( ORBIT_SPEED_PRESETS.find(
(p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD, (p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD,
)?.label ?? `${value.toFixed(2)}\u00d7`; )?.label ?? `${value.toFixed(2)}×`;
function handleChange(vals: number[]) { function handleChange(vals: number[]) {
let raw = vals[0]; let raw = vals[0];

View File

@ -13,6 +13,7 @@ import {
X, X,
Navigation, Navigation,
Building2, Building2,
Eye,
} from "lucide-react"; } from "lucide-react";
import type { FlightState } from "@/lib/opensky"; import type { FlightState } from "@/lib/opensky";
import { import {
@ -28,11 +29,18 @@ import { airlineLogoCandidates } from "@/lib/airline-logos";
type FlightCardProps = { type FlightCardProps = {
flight: FlightState | null; flight: FlightState | null;
onClose: () => void; onClose: () => void;
onToggleFpv?: (icao24: string) => void;
isFpvActive?: boolean;
}; };
const loadedLogoUrls = new Set<string>(); const loadedLogoUrls = new Set<string>();
export function FlightCard({ flight, onClose }: FlightCardProps) { export function FlightCard({
flight,
onClose,
onToggleFpv,
isFpvActive = false,
}: FlightCardProps) {
const airline = flight ? lookupAirline(flight.callsign) : null; const airline = flight ? lookupAirline(flight.callsign) : null;
const flightNum = flight ? parseFlightNumber(flight.callsign) : null; const flightNum = flight ? parseFlightNumber(flight.callsign) : null;
const company = const company =
@ -41,6 +49,11 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
const logoCandidates = airlineLogoCandidates(airline); const logoCandidates = airlineLogoCandidates(airline);
const heading = flight?.trueTrack ?? null; const heading = flight?.trueTrack ?? null;
const cardinal = heading !== null ? headingToCardinal(heading) : null; const cardinal = heading !== null ? headingToCardinal(heading) : null;
const canEnterFpv =
flight != null &&
flight.longitude != null &&
flight.latitude != null &&
!flight.onGround;
const [logoIndexByAirline, setLogoIndexByAirline] = useState< const [logoIndexByAirline, setLogoIndexByAirline] = useState<
Record<string, number> Record<string, number>
>({}); >({});
@ -132,15 +145,56 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
</p> </p>
</div> </div>
</div> </div>
<motion.button <div className="flex items-center gap-1.5">
onClick={onClose} {onToggleFpv && (
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12" <motion.button
whileHover={{ scale: 1.1 }} onClick={() =>
whileTap={{ scale: 0.9 }} (isFpvActive || canEnterFpv) &&
aria-label="Deselect flight" flight &&
> onToggleFpv(flight.icao24)
<X className="h-3 w-3 text-white/40" /> }
</motion.button> disabled={!isFpvActive && !canEnterFpv}
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
isFpvActive
? "bg-emerald-500/20 text-emerald-400"
: !canEnterFpv
? "bg-white/4 text-white/15 cursor-not-allowed"
: "bg-white/6 text-white/40 hover:bg-white/12"
}`}
whileHover={
isFpvActive || canEnterFpv ? { scale: 1.1 } : {}
}
whileTap={isFpvActive || canEnterFpv ? { scale: 0.9 } : {}}
aria-label={
isFpvActive
? "Exit first person view"
: canEnterFpv
? "First person view"
: "First person view unavailable"
}
title={
isFpvActive
? "Exit FPV (F)"
: canEnterFpv
? "First Person View (F)"
: flight?.onGround
? "FPV unavailable (aircraft on ground)"
: "FPV unavailable (no position data)"
}
>
<Eye className="h-3 w-3" />
</motion.button>
)}
<motion.button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Deselect flight"
>
<X className="h-3 w-3 text-white/40" />
</motion.button>
</div>
</div> </div>
{company && ( {company && (
@ -172,14 +226,17 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
icon={<Compass className="h-3 w-3" />} icon={<Compass className="h-3 w-3" />}
label="Heading" label="Heading"
value={ value={
heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—" heading !== null && Number.isFinite(heading)
? `${Math.round(heading)}° ${cardinal}`
: "—"
} }
/> />
<Metric <Metric
icon={<ArrowDown className="h-3 w-3" />} icon={<ArrowDown className="h-3 w-3" />}
label="V/S" label="V/S"
value={ value={
flight.verticalRate !== null flight.verticalRate !== null &&
Number.isFinite(flight.verticalRate)
? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)} m/s` ? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)} m/s`
: "—" : "—"
} }
@ -201,20 +258,25 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
className="h-3 w-3 text-white/25" className="h-3 w-3 text-white/25"
style={{ style={{
transform: transform:
heading !== null ? `rotate(${heading}deg)` : undefined, heading !== null && Number.isFinite(heading)
? `rotate(${heading}deg)`
: undefined,
}} }}
/> />
<p className="text-[11px] font-medium tracking-wide text-white/40"> <p className="text-[11px] font-medium tracking-wide text-white/40">
Heading {cardinal} Heading {cardinal}
{flight.latitude !== null && flight.longitude !== null && ( {flight.latitude !== null &&
<span className="text-white/20"> flight.longitude !== null &&
{" "} Number.isFinite(flight.latitude) &&
· {Math.abs(flight.latitude).toFixed(2)}° Number.isFinite(flight.longitude) && (
{flight.latitude >= 0 ? "N" : "S"},{" "} <span className="text-white/20">
{Math.abs(flight.longitude).toFixed(2)}° {" "}
{flight.longitude >= 0 ? "E" : "W"} · {Math.abs(flight.latitude).toFixed(2)}°
</span> {flight.latitude >= 0 ? "N" : "S"},{" "}
)} {Math.abs(flight.longitude).toFixed(2)}°
{flight.longitude >= 0 ? "E" : "W"}
</span>
)}
</p> </p>
</div> </div>
)} )}

View File

@ -0,0 +1,272 @@
"use client";
import { useRef, useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { motion } from "motion/react";
import { X, Eye, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
import type { FlightState } from "@/lib/opensky";
import { formatCallsign, headingToCardinal } from "@/lib/flight-utils";
import { lookupAirline } from "@/lib/airlines";
import { airlineLogoCandidates } from "@/lib/airline-logos";
type FpvHudProps = {
flight: FlightState;
onExit: () => void;
};
const COMPASS_LABELS: Record<number, string> = {
0: "N",
45: "NE",
90: "E",
135: "SE",
180: "S",
225: "SW",
270: "W",
315: "NW",
};
function CompassRibbon({ heading }: { heading: number | null }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio ?? 1;
const w = 260;
const h = 32;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const hdg = heading ?? 0;
const cx = w / 2;
const pxPerDeg = 2.2;
for (let deg = -360; deg <= 720; deg += 5) {
const normDeg = ((deg % 360) + 360) % 360;
const offset = (((deg - hdg + 540) % 360) - 180) * pxPerDeg;
const x = cx + offset;
if (x < -10 || x > w + 10) continue;
const isMajor = normDeg % 45 === 0;
const isMinor = normDeg % 15 === 0;
const isTiny = normDeg % 5 === 0;
if (isMajor) {
ctx.strokeStyle = "rgba(255,255,255,0.45)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, h - 1);
ctx.lineTo(x, h - 10);
ctx.stroke();
const label = COMPASS_LABELS[normDeg] ?? `${normDeg}`;
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.font = "bold 9px Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText(label, x, h - 14);
} else if (isMinor) {
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, h - 1);
ctx.lineTo(x, h - 7);
ctx.stroke();
} else if (isTiny) {
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x, h - 1);
ctx.lineTo(x, h - 4);
ctx.stroke();
}
}
ctx.fillStyle = "rgba(56, 189, 248, 0.8)";
ctx.beginPath();
ctx.moveTo(cx - 4, 0);
ctx.lineTo(cx + 4, 0);
ctx.lineTo(cx, 6);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(56, 189, 248, 0.4)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, 6);
ctx.lineTo(cx, h);
ctx.stroke();
}, [heading]);
return (
<canvas
ref={canvasRef}
className="block"
style={{ width: 260, height: 32 }}
aria-label={
heading !== null ? `Heading ${Math.round(heading)}°` : "No heading data"
}
/>
);
}
export function FpvHud({ flight, onExit }: FpvHudProps) {
const altFeet =
flight.baroAltitude !== null && Number.isFinite(flight.baroAltitude)
? Math.round(flight.baroAltitude * 3.28084)
: null;
const speedKts =
flight.velocity !== null && Number.isFinite(flight.velocity)
? Math.round(flight.velocity * 1.944)
: null;
const heading =
flight.trueTrack !== null && Number.isFinite(flight.trueTrack)
? flight.trueTrack
: null;
const cardinal = heading !== null ? headingToCardinal(heading) : null;
const vs = flight.verticalRate;
const vsFpm =
vs !== null && Number.isFinite(vs) ? Math.round(vs * 196.85) : null;
const vsDisplay = vsFpm !== null ? `${vsFpm > 0 ? "+" : ""}${vsFpm}` : null;
const airline = useMemo(
() => lookupAirline(flight.callsign),
[flight.callsign],
);
const logoUrl = useMemo(() => {
return airlineLogoCandidates(airline)[0] ?? null;
}, [airline]);
const [logoErrorUrl, setLogoErrorUrl] = useState<string | null>(null);
const logoError = logoUrl !== null && logoUrl === logoErrorUrl;
const vsIcon =
vs !== null && Number.isFinite(vs) ? (
vs > 0.5 ? (
<ArrowUp className="h-3 w-3" />
) : vs < -0.5 ? (
<ArrowDown className="h-3 w-3" />
) : (
<Minus className="h-3 w-3" />
)
) : null;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="pointer-events-auto fixed bottom-[calc(1.5rem+env(safe-area-inset-bottom))] left-1/2 z-50 -translate-x-1/2 sm:bottom-[calc(1.5rem+env(safe-area-inset-bottom))]"
>
<div
className="flex w-[min(92vw,460px)] flex-col items-center gap-0 overflow-hidden rounded-xl border border-white/8 bg-black/70 pb-1 shadow-[0_8px_32px_rgba(0,0,0,0.6)] backdrop-blur-3xl md:w-max"
role="status"
aria-live="polite"
aria-label="First person view flight instruments"
>
<div className="w-full border-b border-white/6 px-2 pt-1.5 pb-0.5 sm:px-2.5">
<div
className="mx-auto w-fit overflow-hidden rounded-md"
style={{ width: 260 }}
>
<CompassRibbon heading={heading} />
</div>
<p className="mt-0 text-center text-[10px] font-bold tabular-nums text-sky-400/70">
{heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—"}
</p>
</div>
<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">
{logoUrl && !logoError ? (
<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">
<Image
src={logoUrl}
alt={airline ? `${airline} logo` : "Airline logo"}
fill
sizes="32px"
className="object-contain p-1"
unoptimized
onError={() => setLogoErrorUrl(logoUrl)}
/>
</span>
) : (
<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>
)}
<div className="min-w-0">
<p className="truncate text-[12px] font-bold tracking-wide text-white/90 sm:text-[13px]">
{formatCallsign(flight.callsign)}
</p>
<p className="truncate text-[9px] font-medium uppercase tracking-widest text-white/30">
{airline ?? flight.originCountry}
</p>
</div>
</div>
<div className="flex min-w-12 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
<div className="flex items-center gap-0.5 text-white/30">
<ArrowUp className="h-2 w-2" />
<span className="text-[8px] font-semibold uppercase tracking-wider">
ALT
</span>
</div>
<p className="text-[13px] font-bold tabular-nums text-white/90">
{altFeet !== null ? altFeet.toLocaleString() : "—"}
</p>
<p className="text-[8px] font-medium text-white/25">ft</p>
</div>
<div className="flex min-w-11 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-14 sm:px-2.5">
<div className="flex items-center gap-0.5 text-white/30">
<Gauge className="h-2 w-2" />
<span className="text-[8px] font-semibold uppercase tracking-wider">
SPD
</span>
</div>
<p className="text-[13px] font-bold tabular-nums text-white/90">
{speedKts ?? "—"}
</p>
<p className="text-[8px] font-medium text-white/25">kts</p>
</div>
<div className="flex min-w-12 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
<div className="flex items-center gap-0.5 text-white/30">
{vsIcon ?? <Minus className="h-2 w-2" />}
<span className="text-[8px] font-semibold uppercase tracking-wider">
V/S
</span>
</div>
<p
className={`text-[13px] font-bold tabular-nums ${
vs !== null && vs > 0.5
? "text-emerald-400/80"
: vs !== null && vs < -0.5
? "text-amber-400/80"
: "text-white/90"
}`}
>
{vsDisplay ?? "—"}
</p>
<p className="text-[8px] font-medium text-white/25">fpm</p>
</div>
<button
onClick={onExit}
className="flex items-center gap-1 px-2.5 py-1.5 text-white/40 transition-colors hover:bg-white/5 hover:text-white/60 sm:px-2.5"
aria-label="Exit first person view"
title="Exit FPV (Esc)"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</motion.div>
);
}

View File

@ -9,6 +9,7 @@ const SHORTCUTS = [
{ key: "R", description: "Reset view" }, { key: "R", description: "Reset view" },
{ key: "O", description: "Toggle orbit" }, { key: "O", description: "Toggle orbit" },
{ key: "/", description: "Open search" }, { key: "/", description: "Open search" },
{ key: "F", description: "First person view" },
{ key: "?", description: "Shortcuts help" }, { key: "?", description: "Shortcuts help" },
{ key: "Esc", description: "Close / Deselect" }, { key: "Esc", description: "Close / Deselect" },
] as const; ] as const;

View File

@ -11,18 +11,17 @@ type MapAttributionProps = {
const SM_BREAKPOINT = 640; const SM_BREAKPOINT = 640;
function getInitialExpanded(): boolean {
if (typeof window === "undefined") return true;
return window.innerWidth >= SM_BREAKPOINT;
}
export function MapAttribution({ styleId }: MapAttributionProps) { export function MapAttribution({ styleId }: MapAttributionProps) {
const [expanded, setExpanded] = useState(getInitialExpanded); const [expanded, setExpanded] = useState(false);
const attributions = getAttributions(styleId); const attributions = getAttributions(styleId);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const toggle = useCallback(() => setExpanded((prev) => !prev), []); const toggle = useCallback(() => setExpanded((prev) => !prev), []);
useEffect(() => {
setExpanded(window.innerWidth >= SM_BREAKPOINT);
}, []);
// Close on outside click for small screens // Close on outside click for small screens
useEffect(() => { useEffect(() => {
if (!expanded) return; if (!expanded) return;

View File

@ -19,6 +19,7 @@ const CREDIT_TIER_EMERGENCY = 200;
const RATE_LIMIT_BACKOFF_MS = 30_000; const RATE_LIMIT_BACKOFF_MS = 30_000;
const VISIBILITY_RESUME_STALE_MS = 60_000; const VISIBILITY_RESUME_STALE_MS = 60_000;
const FPV_BBOX_RADIUS = 2;
function adaptiveInterval(creditsRemaining: number | null): number { function adaptiveInterval(creditsRemaining: number | null): number {
if (creditsRemaining === null) return BASE_POLL_MS; if (creditsRemaining === null) return BASE_POLL_MS;
@ -28,7 +29,15 @@ function adaptiveInterval(creditsRemaining: number | null): number {
return BASE_POLL_MS; return BASE_POLL_MS;
} }
export function useFlights(city: City | null) { /**
* Fetches flights via OpenSky. In FPV mode the bbox moves with the tracked
* aircraft (4×4° = 1 API credit). City changes are ignored while in FPV.
*/
export function useFlights(
city: City | null,
fpvIcao24: string | null = null,
fpvSeedCenter: { lng: number; lat: number } | null = null,
) {
const [flights, setFlights] = useState<FlightState[]>([]); const [flights, setFlights] = useState<FlightState[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -42,6 +51,32 @@ export function useFlights(city: City | null) {
const creditsRef = useRef<number | null>(null); const creditsRef = useRef<number | null>(null);
const lastFetchRef = useRef(0); const lastFetchRef = useRef(0);
const fpvCenterRef = useRef<{ lng: number; lat: number } | null>(null);
const fpvSeedCenterRef = useRef<{ lng: number; lat: number } | null>(
fpvSeedCenter,
);
const fpvIcao24Ref = useRef<string | null>(fpvIcao24);
const fpvSeedRef = useRef<string | null>(null);
const fetchDataRef = useRef<(target: City) => void>(() => {});
fpvIcao24Ref.current = fpvIcao24;
fpvSeedCenterRef.current = fpvSeedCenter;
useEffect(() => {
if (!fpvIcao24) {
fpvCenterRef.current = null;
fpvSeedRef.current = null;
return;
}
if (fpvSeedRef.current === fpvIcao24) return;
const match = flights.find(
(f) => f.icao24.toLowerCase() === fpvIcao24,
);
if (match?.longitude != null && match?.latitude != null) {
fpvCenterRef.current = { lng: match.longitude, lat: match.latitude };
}
fpvSeedRef.current = fpvIcao24;
}, [fpvIcao24, flights]);
const clearCountdown = useCallback(() => { const clearCountdown = useCallback(() => {
if (countdownRef.current) { if (countdownRef.current) {
@ -75,10 +110,11 @@ export function useFlights(city: City | null) {
const scheduleNext = useCallback( const scheduleNext = useCallback(
(target: City, delayMs: number) => { (target: City, delayMs: number) => {
clearSchedule(); clearSchedule();
timerRef.current = setTimeout(() => fetchData(target), delayMs); timerRef.current = setTimeout(() => {
fetchDataRef.current(target);
}, delayMs);
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps [clearSchedule],
[],
); );
const fetchData = useCallback( const fetchData = useCallback(
@ -90,18 +126,50 @@ export function useFlights(city: City | null) {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
let bbox: [number, number, number, number];
const inFpv = fpvIcao24Ref.current !== null;
if (inFpv && fpvCenterRef.current) {
bbox = bboxFromCenter(
fpvCenterRef.current.lng,
fpvCenterRef.current.lat,
FPV_BBOX_RADIUS,
);
} else if (inFpv && fpvSeedCenterRef.current) {
fpvCenterRef.current = fpvSeedCenterRef.current;
bbox = bboxFromCenter(
fpvSeedCenterRef.current.lng,
fpvSeedCenterRef.current.lat,
FPV_BBOX_RADIUS,
);
} else if (inFpv) {
fpvCenterRef.current = {
lng: target.coordinates[0],
lat: target.coordinates[1],
};
bbox = bboxFromCenter(
target.coordinates[0],
target.coordinates[1],
FPV_BBOX_RADIUS,
);
} else {
bbox = bboxFromCenter(
target.coordinates[0],
target.coordinates[1],
target.radius,
);
}
const bbox = bboxFromCenter(
target.coordinates[0],
target.coordinates[1],
target.radius,
);
const result = await fetchFlightsByBbox(...bbox, controller.signal); const result = await fetchFlightsByBbox(...bbox, controller.signal);
if (result.rateLimited) { if (result.rateLimited) {
const retryDelayMs =
result.retryAfterSeconds && result.retryAfterSeconds > 0
? result.retryAfterSeconds * 1000
: RATE_LIMIT_BACKOFF_MS;
setRateLimited(true); setRateLimited(true);
startCountdown(RATE_LIMIT_BACKOFF_MS); startCountdown(retryDelayMs);
scheduleNext(target, RATE_LIMIT_BACKOFF_MS); scheduleNext(target, retryDelayMs);
return; return;
} }
@ -109,6 +177,17 @@ export function useFlights(city: City | null) {
clearCountdown(); clearCountdown();
setFlights(result.flights); setFlights(result.flights);
lastFetchRef.current = Date.now(); lastFetchRef.current = Date.now();
if (inFpv && fpvIcao24Ref.current) {
const tracked = result.flights.find(
(f) => f.icao24.toLowerCase() === fpvIcao24Ref.current,
);
if (tracked?.longitude != null && tracked?.latitude != null) {
fpvCenterRef.current = {
lng: tracked.longitude,
lat: tracked.latitude,
};
}
}
if (result.creditsRemaining !== null) { if (result.creditsRemaining !== null) {
creditsRef.current = result.creditsRemaining; creditsRef.current = result.creditsRemaining;
@ -121,7 +200,6 @@ export function useFlights(city: City | null) {
const isAbort = err instanceof Error && err.name === "AbortError"; const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) return; if (isAbort) return;
setError(err instanceof Error ? err.message : "Unknown error"); setError(err instanceof Error ? err.message : "Unknown error");
setFlights([]);
scheduleNext(target, RATE_LIMIT_BACKOFF_MS); scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
} finally { } finally {
setLoading(false); setLoading(false);
@ -130,26 +208,30 @@ export function useFlights(city: City | null) {
[scheduleNext, startCountdown, clearCountdown], [scheduleNext, startCountdown, clearCountdown],
); );
useEffect(() => {
fetchDataRef.current = (target: City) => {
void fetchData(target);
};
}, [fetchData]);
useEffect(() => { useEffect(() => {
if (!city) return; if (!city) return;
const activeCity = city; const activeCity = city;
function onVisibilityChange() { function onVisibilityChange() {
if (document.visibilityState === "visible") { if (document.visibilityState !== "visible") return;
const elapsed = Date.now() - lastFetchRef.current;
if (elapsed >= VISIBILITY_RESUME_STALE_MS) { const elapsed = Date.now() - lastFetchRef.current;
clearSchedule();
fetchData(activeCity); if (elapsed >= VISIBILITY_RESUME_STALE_MS) {
} else {
const interval = adaptiveInterval(creditsRef.current);
const remaining = Math.max(1_000, interval - elapsed);
clearSchedule();
scheduleNext(activeCity, remaining);
}
} else {
clearSchedule(); clearSchedule();
fetchData(activeCity);
} else {
const interval = adaptiveInterval(creditsRef.current);
const remaining = Math.max(1_000, interval - elapsed);
clearSchedule();
scheduleNext(activeCity, remaining);
} }
} }
@ -160,6 +242,8 @@ export function useFlights(city: City | null) {
}, [city, fetchData, scheduleNext, clearSchedule]); }, [city, fetchData, scheduleNext, clearSchedule]);
useEffect(() => { useEffect(() => {
if (fpvIcao24Ref.current !== null) return;
clearSchedule(); clearSchedule();
if (!city) { if (!city) {
@ -182,5 +266,29 @@ export function useFlights(city: City | null) {
}; };
}, [city, fetchData, clearCountdown, clearSchedule]); }, [city, fetchData, clearCountdown, clearSchedule]);
const prevFpvRef = useRef<string | null>(fpvIcao24);
useEffect(() => {
const wasInFpv = prevFpvRef.current !== null;
const isInFpv = fpvIcao24 !== null;
prevFpvRef.current = fpvIcao24;
if (!wasInFpv && isInFpv) {
clearSchedule();
if (city) fetchData(city);
} else if (wasInFpv && !isInFpv && city) {
fpvCenterRef.current = null;
clearSchedule();
fetchData(city);
}
}, [fpvIcao24, city, clearSchedule, fetchData]);
useEffect(() => {
return () => {
clearSchedule();
abortRef.current?.abort();
clearCountdown();
};
}, [clearSchedule, clearCountdown]);
return { flights, loading, error, rateLimited, retryIn, creditsRemaining }; return { flights, loading, error, rateLimited, retryIn, creditsRemaining };
} }

View File

@ -9,6 +9,8 @@ type ShortcutActions = {
onOpenSearch: () => void; onOpenSearch: () => void;
onToggleHelp: () => void; onToggleHelp: () => void;
onDeselect: () => void; onDeselect: () => void;
onToggleFpv: () => void;
isFpv?: boolean;
}; };
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
@ -40,6 +42,14 @@ export function useKeyboardShortcuts(actions: ShortcutActions) {
if (dialogOpen) return; if (dialogOpen) return;
if (a.isFpv) {
if (e.key === "f" || e.key === "F") {
e.preventDefault();
a.onToggleFpv();
}
return;
}
switch (e.key) { switch (e.key) {
case "n": case "n":
case "N": case "N":
@ -60,6 +70,11 @@ export function useKeyboardShortcuts(actions: ShortcutActions) {
e.preventDefault(); e.preventDefault();
a.onOpenSearch(); a.onOpenSearch();
break; break;
case "f":
case "F":
e.preventDefault();
a.onToggleFpv();
break;
case "?": case "?":
e.preventDefault(); e.preventDefault();
a.onToggleHelp(); a.onToggleHelp();

View File

@ -22,12 +22,15 @@ export type Settings = {
trailDistance: number; trailDistance: number;
showShadows: boolean; showShadows: boolean;
showAltitudeColors: boolean; showAltitudeColors: boolean;
fpvChaseDistance: number;
}; };
const TRAIL_THICKNESS_MIN = 1; const TRAIL_THICKNESS_MIN = 1;
const TRAIL_THICKNESS_MAX = 8; const TRAIL_THICKNESS_MAX = 8;
const TRAIL_DISTANCE_MIN = 12; const TRAIL_DISTANCE_MIN = 12;
const TRAIL_DISTANCE_MAX = 100; const TRAIL_DISTANCE_MAX = 100;
const FPV_CHASE_DISTANCE_MIN = 0.003;
const FPV_CHASE_DISTANCE_MAX = 0.01;
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
@ -45,6 +48,11 @@ function normalizeSettings(input: Settings): Settings {
trailDistance: Math.round( trailDistance: Math.round(
clamp(input.trailDistance, TRAIL_DISTANCE_MIN, TRAIL_DISTANCE_MAX), clamp(input.trailDistance, TRAIL_DISTANCE_MIN, TRAIL_DISTANCE_MAX),
), ),
fpvChaseDistance: clamp(
input.fpvChaseDistance,
FPV_CHASE_DISTANCE_MIN,
FPV_CHASE_DISTANCE_MAX,
),
}; };
} }
@ -57,6 +65,7 @@ const DEFAULT_SETTINGS: Settings = {
trailDistance: 40, trailDistance: 40,
showShadows: true, showShadows: true,
showAltitudeColors: true, showAltitudeColors: true,
fpvChaseDistance: 0.0048,
}; };
const STORAGE_KEY = "aeris:settings"; const STORAGE_KEY = "aeris:settings";
@ -68,7 +77,6 @@ type StorageEnvelope = {
data: Settings; data: Settings;
}; };
/** Validate that a parsed value matches the Settings shape. */
function isValidSettings(obj: unknown): obj is Settings { function isValidSettings(obj: unknown): obj is Settings {
if (typeof obj !== "object" || obj === null) return false; if (typeof obj !== "object" || obj === null) return false;
const s = obj as Record<string, unknown>; const s = obj as Record<string, unknown>;
@ -87,7 +95,11 @@ function isValidSettings(obj: unknown): obj is Settings {
s.trailDistance >= TRAIL_DISTANCE_MIN && s.trailDistance >= TRAIL_DISTANCE_MIN &&
s.trailDistance <= TRAIL_DISTANCE_MAX && s.trailDistance <= TRAIL_DISTANCE_MAX &&
typeof s.showShadows === "boolean" && typeof s.showShadows === "boolean" &&
typeof s.showAltitudeColors === "boolean" typeof s.showAltitudeColors === "boolean" &&
typeof s.fpvChaseDistance === "number" &&
Number.isFinite(s.fpvChaseDistance) &&
s.fpvChaseDistance >= FPV_CHASE_DISTANCE_MIN &&
s.fpvChaseDistance <= FPV_CHASE_DISTANCE_MAX
); );
} }
@ -121,7 +133,7 @@ function saveSettings(settings: Settings): void {
const envelope: StorageEnvelope = { v: STORAGE_VERSION, data: settings }; const envelope: StorageEnvelope = { v: STORAGE_VERSION, data: settings };
localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope)); localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope));
} catch { } catch {
/* quota exceeded or blocked */ /* noop */
} }
} }

View File

@ -72607,3 +72607,24 @@ export function airportToCity(airport: Airport): City {
radius: DEFAULT_RADIUS, radius: DEFAULT_RADIUS,
}; };
} }
/**
* Returns a subset of major airports (those with 3-letter IATA codes that are
* in the CITIES list or have well-known IATA codes). Useful for route inference
* where searching all 72K airports would be too slow.
*
* Cached after first call.
*/
let _majorAirportsCache: Airport[] | null = null;
export function getMajorAirports(): Airport[] {
if (_majorAirportsCache) return _majorAirportsCache;
// All airports with a 3-letter IATA code (major airports tend to have them)
// Filter to those whose IATA is alpha-only (excludes numeric/special codes)
const alphaIata = /^[A-Z]{3}$/;
_majorAirportsCache = AIRPORTS.filter(
(a) => a.iata && alphaIata.test(a.iata.toUpperCase()),
);
return _majorAirportsCache;
}

View File

@ -26,7 +26,7 @@ function lerpColor(a: RGB, b: RGB, t: number): RGB {
export function altitudeToColor( export function altitudeToColor(
altitude: number | null, altitude: number | null,
): [number, number, number, number] { ): [number, number, number, number] {
if (altitude === null) return [100, 100, 100, 200]; if (altitude === null || !Number.isFinite(altitude)) return [100, 100, 100, 200];
const normalized = Math.min(Math.max(altitude / MAX_ALTITUDE_METERS, 0), 1); const normalized = Math.min(Math.max(altitude / MAX_ALTITUDE_METERS, 0), 1);
const t = Math.pow(normalized, 0.4); const t = Math.pow(normalized, 0.4);
@ -46,17 +46,17 @@ export function altitudeToColor(
} }
export function altitudeToElevation(altitude: number | null): number { export function altitudeToElevation(altitude: number | null): number {
if (altitude === null) return 0; if (altitude === null || !Number.isFinite(altitude)) return 0;
return Math.max(altitude * 5, 200); return Math.max(altitude * 5, 200);
} }
export function metersToFeet(meters: number | null): string { export function metersToFeet(meters: number | null): string {
if (meters === null) return "—"; if (meters === null || !Number.isFinite(meters)) return "—";
return `${Math.round(meters * 3.28084).toLocaleString()} ft`; return `${Math.round(meters * 3.28084).toLocaleString()} ft`;
} }
export function msToKnots(ms: number | null): string { export function msToKnots(ms: number | null): string {
if (ms === null) return "—"; if (ms === null || !Number.isFinite(ms)) return "—";
return `${Math.round(ms * 1.94384)} kts`; return `${Math.round(ms * 1.94384)} kts`;
} }
@ -66,8 +66,8 @@ export function formatCallsign(callsign: string | null): string {
} }
export function headingToCardinal(degrees: number | null): string { export function headingToCardinal(degrees: number | null): string {
if (degrees === null) return "—"; if (degrees === null || !Number.isFinite(degrees)) return "—";
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
const index = Math.round(degrees / 45) % 8; const index = ((Math.round(degrees / 45) % 8) + 8) % 8;
return directions[index]; return directions[index];
} }

View File

@ -2,6 +2,8 @@
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 CALLSIGN_CACHE_TTL_MS = 30_000;
export type FlightState = { export type FlightState = {
icao24: string; icao24: string;
@ -26,45 +28,114 @@ type OpenSkyResponse = {
states: (string | number | boolean | null)[][] | null; states: (string | number | boolean | null)[][] | null;
}; };
function parseStates(raw: OpenSkyResponse): FlightState[] { type ParseStateOptions = {
if (!raw.states) return []; includeGround?: boolean;
requireBaroAltitude?: boolean;
};
type RateLimitInfo = {
creditsRemaining: number | null;
retryAfterSeconds: number | null;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
function parseIntegerHeader(value: string | null): number | null {
if (value === null) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function parseRateLimitInfo(response: Response): RateLimitInfo {
return {
creditsRemaining: parseIntegerHeader(
response.headers.get("x-rate-limit-remaining"),
),
retryAfterSeconds: parseIntegerHeader(
response.headers.get("x-rate-limit-retry-after-seconds"),
),
};
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function normalizeBounds(
lower: number,
upper: number,
min: number,
max: number,
): [number, number] {
if (!Number.isFinite(lower) || !Number.isFinite(upper)) {
throw new Error("Invalid bounding box coordinates");
}
const lo = clamp(lower, min, max);
const hi = clamp(upper, min, max);
return lo <= hi ? [lo, hi] : [hi, lo];
}
function parseStateRow(rawState: (string | number | boolean | null)[]): FlightState | null {
if (rawState.length < 17) return null;
const icao24 = typeof rawState[0] === "string" ? rawState[0].toLowerCase() : "";
if (!ICAO24_REGEX.test(icao24)) return null;
const longitude = isFiniteNumber(rawState[5]) ? rawState[5] : null;
const latitude = isFiniteNumber(rawState[6]) ? rawState[6] : null;
const baroAltitude = isFiniteNumber(rawState[7]) ? rawState[7] : null;
return {
icao24,
callsign: typeof rawState[1] === "string" ? rawState[1].trim() || null : null,
originCountry:
typeof rawState[2] === "string" ? rawState[2] : "Unknown",
longitude,
latitude,
baroAltitude,
onGround: rawState[8] === true,
velocity: isFiniteNumber(rawState[9]) ? rawState[9] : null,
trueTrack: isFiniteNumber(rawState[10]) ? rawState[10] : null,
verticalRate: isFiniteNumber(rawState[11]) ? rawState[11] : null,
geoAltitude: isFiniteNumber(rawState[13]) ? rawState[13] : null,
squawk: typeof rawState[14] === "string" ? rawState[14] : null,
spiFlag: rawState[15] === true,
positionSource: isFiniteNumber(rawState[16]) ? rawState[16] : 0,
category: isFiniteNumber(rawState[17]) ? rawState[17] : null,
};
}
function parseStates(raw: OpenSkyResponse, options?: ParseStateOptions): FlightState[] {
if (!raw || !Array.isArray(raw.states)) return [];
const includeGround = options?.includeGround ?? false;
const requireBaroAltitude = options?.requireBaroAltitude ?? true;
return raw.states return raw.states
.map((s) => ({ .map(parseStateRow)
icao24: s[0] as string, .filter((state): state is FlightState => state !== null)
callsign: (s[1] as string)?.trim() || null,
originCountry: s[2] as string,
longitude: s[5] as number | null,
latitude: s[6] as number | null,
baroAltitude: s[7] as number | null,
onGround: s[8] as boolean,
velocity: s[9] as number | null,
trueTrack: s[10] as number | null,
verticalRate: s[11] as number | null,
geoAltitude: s[13] as number | null,
squawk: s[14] as string | null,
spiFlag: s[15] as boolean,
positionSource: s[16] as number,
category: (s[17] as number | null) ?? null,
}))
.filter( .filter(
(f) => (f) =>
f.longitude !== null && f.longitude !== null &&
f.latitude !== null && f.latitude !== null &&
!f.onGround && (includeGround || !f.onGround) &&
f.baroAltitude !== null, (!requireBaroAltitude || f.baroAltitude !== null),
); );
} }
function normalizeCallsign(value: string | null): string {
if (!value) return "";
return value.trim().toUpperCase().replace(/\s+/g, "");
}
export type FetchResult = { export type FetchResult = {
flights: FlightState[]; flights: FlightState[];
rateLimited: boolean; rateLimited: boolean;
creditsRemaining: number | null; creditsRemaining: number | null;
retryAfterSeconds: number | null;
}; };
const clamp = (v: number, lo: number, hi: number) =>
Math.min(Math.max(v, lo), hi);
export async function fetchFlightsByBbox( export async function fetchFlightsByBbox(
lamin: number, lamin: number,
lamax: number, lamax: number,
@ -72,10 +143,8 @@ export async function fetchFlightsByBbox(
lomax: number, lomax: number,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<FetchResult> { ): Promise<FetchResult> {
const la0 = clamp(lamin, -90, 90); const [la0, la1] = normalizeBounds(lamin, lamax, -90, 90);
const la1 = clamp(lamax, -90, 90); const [lo0, lo1] = normalizeBounds(lomin, lomax, -180, 180);
const lo0 = clamp(lomin, -180, 180);
const lo1 = clamp(lomax, -180, 180);
const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}&extended=1`; const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}&extended=1`;
@ -89,26 +158,37 @@ export async function fetchFlightsByBbox(
cache: "no-store", cache: "no-store",
signal: controller.signal, signal: controller.signal,
}); });
const rateLimitInfo = parseRateLimitInfo(res);
if (res.status === 429) { if (res.status === 429) {
return { flights: [], rateLimited: true, creditsRemaining: null }; return {
flights: [],
rateLimited: true,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: rateLimitInfo.retryAfterSeconds,
};
} }
if (!res.ok) { if (!res.ok) {
return { flights: [], rateLimited: false, creditsRemaining: null }; return {
flights: [],
rateLimited: false,
creditsRemaining: rateLimitInfo.creditsRemaining,
retryAfterSeconds: null,
};
} }
const data: OpenSkyResponse = await res.json(); const payload = (await res.json()) as unknown;
const creditsRaw = res.headers.get("x-rate-limit-remaining"); const data =
const creditsRemaining = typeof payload === "object" && payload !== null
creditsRaw !== null ? parseInt(creditsRaw, 10) : null; ? (payload as OpenSkyResponse)
: { time: 0, states: null };
return { return {
flights: parseStates(data), flights: parseStates(data),
rateLimited: false, rateLimited: false,
creditsRemaining: Number.isNaN(creditsRemaining) creditsRemaining: rateLimitInfo.creditsRemaining,
? null retryAfterSeconds: null,
: creditsRemaining,
}; };
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
@ -129,3 +209,251 @@ export function bboxFromCenter(
): [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]; return [lat - radiusDeg, lat + radiusDeg, lng - radiusDeg, lng + radiusDeg];
} }
/**
* 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);
}
}
type CallsignLookupResult = {
flight: FlightState | null;
creditsRemaining: number | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
};
const callsignLookupCache = new Map<
string,
{ timestamp: number; result: 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;
}
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,
});
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);
}
}
const SEGMENT_DELAY_MS = 200;
/**
* 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,
};
}