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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -44,3 +44,4 @@ next-env.d.ts
|
|||||||
|
|
||||||
# local documentation
|
# local documentation
|
||||||
docs.txt
|
docs.txt
|
||||||
|
ROADMAP.local.md
|
||||||
|
|||||||
@ -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(
|
||||||
|
(info: PickingInfo<FlightState> | null) => {
|
||||||
|
if (fpvIcao24) return;
|
||||||
if (info?.object) {
|
if (info?.object) {
|
||||||
setSelectedIcao24((prev) =>
|
const icao24 = info.object.icao24.toLowerCase();
|
||||||
prev === info.object!.icao24 ? null : info.object!.icao24,
|
setSelectedIcao24((prev) => (prev === icao24 ? null : icao24));
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedIcao24(null);
|
setSelectedIcao24(null);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[fpvIcao24],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeselectFlight = useCallback(() => {
|
const handleDeselectFlight = useCallback(() => {
|
||||||
|
if (fpvIcao24) {
|
||||||
|
setSelectedIcao24(fpvIcao24);
|
||||||
|
setFpvIcao24(null);
|
||||||
|
} else {
|
||||||
setSelectedIcao24(null);
|
setSelectedIcao24(null);
|
||||||
}, []);
|
}
|
||||||
|
}, [fpvIcao24]);
|
||||||
|
|
||||||
|
const handleToggleFpv = useCallback(
|
||||||
|
(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,42 +616,63 @@ 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"
|
||||||
>
|
>
|
||||||
|
{!fpvIcao24 && (
|
||||||
<div className="pointer-events-auto absolute left-3 top-3 flex items-center gap-3 sm:left-4 sm:top-4">
|
<div className="pointer-events-auto absolute left-3 top-3 flex items-center gap-3 sm:left-4 sm:top-4">
|
||||||
<Brand isDark={mapStyle.dark} />
|
<Brand isDark={mapStyle.dark} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fpvIcao24 && (
|
||||||
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
|
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
|
||||||
<FlightCard flight={displayFlight} onClose={handleDeselectFlight} />
|
<FlightCard
|
||||||
|
flight={displayFlight}
|
||||||
|
onClose={handleDeselectFlight}
|
||||||
|
onToggleFpv={handleToggleFpv}
|
||||||
|
isFpvActive={
|
||||||
|
fpvIcao24 !== null && fpvIcao24 === displayFlight?.icao24
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fpvIcao24 && (
|
||||||
<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">
|
<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">
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleToggleHelp}
|
onClick={handleToggleHelp}
|
||||||
@ -395,9 +730,14 @@ function FlightTrackerInner() {
|
|||||||
onSelectCity={setActiveCity}
|
onSelectCity={setActiveCity}
|
||||||
activeStyle={mapStyle}
|
activeStyle={mapStyle}
|
||||||
onSelectStyle={setMapStyle}
|
onSelectStyle={setMapStyle}
|
||||||
|
flights={displayFlights}
|
||||||
|
activeFlightIcao24={selectedIcao24}
|
||||||
|
onLookupFlight={handleLookupFlight}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fpvIcao24 && (
|
||||||
<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">
|
<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">
|
||||||
<StatusBar
|
<StatusBar
|
||||||
flightCount={flights.length}
|
flightCount={flights.length}
|
||||||
@ -410,18 +750,29 @@ function FlightTrackerInner() {
|
|||||||
onRandomAirport={handleRandomAirport}
|
onRandomAirport={handleRandomAirport}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fpvIcao24 && (
|
||||||
<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">
|
<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">
|
||||||
<CameraControls />
|
<CameraControls />
|
||||||
<AltitudeLegend />
|
<AltitudeLegend />
|
||||||
<MapAttribution styleId={mapStyle.id} />
|
<MapAttribution styleId={mapStyle.id} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!fpvIcao24 && (
|
||||||
<KeyboardShortcutsHelp
|
<KeyboardShortcutsHelp
|
||||||
open={showHelp}
|
open={showHelp}
|
||||||
onClose={() => setShowHelp(false)}
|
onClose={() => setShowHelp(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{fpvIcao24 && fpvFlightOrCached && (
|
||||||
|
<FpvHud flight={fpvFlightOrCached} onExit={handleExitFpv} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/components/map/camera-controller-utils.ts
Normal file
93
src/components/map/camera-controller-utils.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
257
src/components/ui/airport-search-input.tsx
Normal file
257
src/components/ui/airport-search-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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];
|
||||||
|
|||||||
@ -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,6 +145,46 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{onToggleFpv && (
|
||||||
|
<motion.button
|
||||||
|
onClick={() =>
|
||||||
|
(isFpvActive || canEnterFpv) &&
|
||||||
|
flight &&
|
||||||
|
onToggleFpv(flight.icao24)
|
||||||
|
}
|
||||||
|
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
|
<motion.button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
|
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
|
||||||
@ -142,6 +195,7 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
|
|||||||
<X className="h-3 w-3 text-white/40" />
|
<X className="h-3 w-3 text-white/40" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{company && (
|
{company && (
|
||||||
<div className="mt-2.5 flex items-center gap-1.5">
|
<div className="mt-2.5 flex items-center gap-1.5">
|
||||||
@ -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,12 +258,17 @@ 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 &&
|
||||||
|
flight.longitude !== null &&
|
||||||
|
Number.isFinite(flight.latitude) &&
|
||||||
|
Number.isFinite(flight.longitude) && (
|
||||||
<span className="text-white/20">
|
<span className="text-white/20">
|
||||||
{" "}
|
{" "}
|
||||||
· {Math.abs(flight.latitude).toFixed(2)}°
|
· {Math.abs(flight.latitude).toFixed(2)}°
|
||||||
|
|||||||
272
src/components/ui/fpv-hud.tsx
Normal file
272
src/components/ui/fpv-hud.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
const bbox = bboxFromCenter(
|
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[0],
|
||||||
target.coordinates[1],
|
target.coordinates[1],
|
||||||
target.radius,
|
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,13 +208,20 @@ 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;
|
const elapsed = Date.now() - lastFetchRef.current;
|
||||||
|
|
||||||
if (elapsed >= VISIBILITY_RESUME_STALE_MS) {
|
if (elapsed >= VISIBILITY_RESUME_STALE_MS) {
|
||||||
@ -148,9 +233,6 @@ export function useFlights(city: City | null) {
|
|||||||
clearSchedule();
|
clearSchedule();
|
||||||
scheduleNext(activeCity, remaining);
|
scheduleNext(activeCity, remaining);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
clearSchedule();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user