From a08f1c7250bbf90017f7976fc724b0ba0ba3caa3 Mon Sep 17 00:00:00 2001 From: kew <108450560+kewonit@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:31:17 +0530 Subject: [PATCH] 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. --- .gitignore | 1 + src/components/flight-tracker.tsx | 587 ++++++++++++++---- src/components/map/camera-controller-utils.ts | 93 +++ src/components/map/camera-controller.tsx | 357 ++++++++++- src/components/map/flight-layers.tsx | 78 ++- src/components/ui/airport-search-input.tsx | 257 ++++++++ src/components/ui/control-panel.tsx | 218 ++++++- src/components/ui/flight-card.tsx | 106 +++- src/components/ui/fpv-hud.tsx | 272 ++++++++ src/components/ui/keyboard-shortcuts-help.tsx | 1 + src/components/ui/map-attribution.tsx | 11 +- src/hooks/use-flights.ts | 156 ++++- src/hooks/use-keyboard-shortcuts.ts | 15 + src/hooks/use-settings.tsx | 18 +- src/lib/airports.ts | 21 + src/lib/flight-utils.ts | 12 +- src/lib/opensky.ts | 402 ++++++++++-- 17 files changed, 2358 insertions(+), 247 deletions(-) create mode 100644 src/components/map/camera-controller-utils.ts create mode 100644 src/components/ui/airport-search-input.tsx create mode 100644 src/components/ui/fpv-hud.tsx diff --git a/.gitignore b/.gitignore index ab06e51..6128ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ next-env.d.ts # local documentation docs.txt +ROADMAP.local.md diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 5999da6..ea85181 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -8,13 +8,14 @@ import { useRef, useSyncExternalStore, } from "react"; -import { motion } from "motion/react"; +import { motion, AnimatePresence } from "motion/react"; 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 { AirportLayer } from "@/components/map/airport-layer"; import { FlightLayers } from "@/components/map/flight-layers"; import { FlightCard } from "@/components/ui/flight-card"; +import { FpvHud } from "@/components/ui/fpv-hud"; import { KeyboardShortcutsHelp } from "@/components/ui/keyboard-shortcuts-help"; import { ControlPanel } from "@/components/ui/control-panel"; 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 { CITIES, type City } from "@/lib/cities"; 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 { Github, Star, Keyboard } from "lucide-react"; @@ -65,6 +71,7 @@ const HIGH_TRAFFIC_IATA_SET = new Set(HIGH_TRAFFIC_IATA); const HIGH_TRAFFIC_AIRPORTS = AIRPORTS.filter((airport) => HIGH_TRAFFIC_IATA_SET.has(airport.iata.toUpperCase()), ); +const ICAO24_REGEX = /^[0-9a-f]{6}$/i; const subscribeNoop = () => () => {}; @@ -107,12 +114,46 @@ function syncCityToUrl(city: City): void { try { const url = new URL(window.location.href); url.searchParams.set("city", city.iata); + url.searchParams.delete("from"); + url.searchParams.delete("to"); + url.searchParams.delete("fpv"); window.history.replaceState(null, "", url.toString()); } catch { /* 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 { try { const id = localStorage.getItem(STYLE_STORAGE_KEY); @@ -157,6 +198,19 @@ function pickRandomAirportCity(excludeIata?: string): City { 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() { const hydratedCity = useSyncExternalStore( subscribeNoop, @@ -174,6 +228,22 @@ function FlightTrackerInner() { const [selectedIcao24, setSelectedIcao24] = useState(null); const [showHelp, setShowHelp] = useState(false); const [repoStars, setRepoStars] = useState(null); + const [followIcao24, setFollowIcao24] = useState(null); + const [fpvIcao24, setFpvIcao24] = useState(null); + + const pendingFpvRef = useRef(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 mapStyle = styleOverride ?? hydratedStyle; @@ -182,6 +252,8 @@ function FlightTrackerInner() { const setActiveCity = useCallback((city: City) => { setCityOverride(city); setSelectedIcao24(null); + setFpvIcao24(null); + setFollowIcao24(null); syncCityToUrl(city); }, []); @@ -189,24 +261,157 @@ function FlightTrackerInner() { setStyleOverride(style); saveMapStyle(style); }, []); - const { flights, loading, rateLimited, retryIn } = useFlights(activeCity); - const trails = useTrailHistory(flights); + const { flights, loading, rateLimited, retryIn } = useFlights( + activeCity, + fpvIcao24, + fpvSeedCenter, + ); + + const displayFlights = flights; + const displayTrails = useTrailHistory(displayFlights); const selectedFlight = useMemo(() => { if (!selectedIcao24) return null; - return flights.find((f) => f.icao24 === selectedIcao24) ?? null; - }, [selectedIcao24, flights]); + return ( + 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(null); useEffect(() => { - if (selectedFlight) lastKnownFlightRef.current = selectedFlight; - if (!selectedIcao24) lastKnownFlightRef.current = null; - }, [selectedFlight, selectedIcao24]); + syncFpvToUrl(fpvIcao24, activeCity); + }, [fpvIcao24, activeCity]); - // Safe: ref only changes in the effect above, which runs after state-driven re-renders. - const displayFlight = - // eslint-disable-next-line react-hooks/refs - selectedFlight ?? (selectedIcao24 ? lastKnownFlightRef.current : null); + const fpvLookupDoneRef = useRef(false); + useEffect(() => { + const pending = pendingFpvRef.current; + 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(null); useEffect(() => { @@ -218,17 +423,17 @@ function FlightTrackerInner() { missingSinceRef.current = null; return; } - // Flight is selected but not in the current flights list. const now = Date.now(); if (missingSinceRef.current == null) { missingSinceRef.current = now; return; } if (now - missingSinceRef.current >= 30_000) { - setSelectedIcao24(null); + const timer = setTimeout(() => setSelectedIcao24(null), 0); missingSinceRef.current = null; + return () => clearTimeout(timer); } - }, [selectedIcao24, selectedFlight, flights]); + }, [selectedIcao24, selectedFlight, displayFlights]); useEffect(() => { let mounted = true; @@ -252,19 +457,55 @@ function FlightTrackerInner() { }; }, []); - const handleClick = useCallback((info: PickingInfo | null) => { - if (info?.object) { - setSelectedIcao24((prev) => - prev === info.object!.icao24 ? null : info.object!.icao24, - ); + const handleClick = useCallback( + (info: PickingInfo | null) => { + if (fpvIcao24) return; + if (info?.object) { + const icao24 = info.object.icao24.toLowerCase(); + setSelectedIcao24((prev) => (prev === icao24 ? null : icao24)); + } else { + setSelectedIcao24(null); + } + }, + [fpvIcao24], + ); + + const handleDeselectFlight = useCallback(() => { + if (fpvIcao24) { + setSelectedIcao24(fpvIcao24); + setFpvIcao24(null); } else { setSelectedIcao24(null); } - }, []); + }, [fpvIcao24]); - const handleDeselectFlight = useCallback(() => { - setSelectedIcao24(null); - }, []); + 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(() => { window.dispatchEvent(new CustomEvent("aeris:north-up")); @@ -295,6 +536,79 @@ function FlightTrackerInner() { 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 => { + 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({ onNorthUp: handleNorthUp, onResetView: handleResetView, @@ -302,126 +616,163 @@ function FlightTrackerInner() { onOpenSearch: handleOpenSearch, onToggleHelp: handleToggleHelp, onDeselect: handleDeselectFlight, + onToggleFpv: handleToggleFpvKey, + isFpv: fpvIcao24 !== null, }); return (
- - + + - +
-
- -
+ {!fpvIcao24 && ( +
+ +
+ )} -
- -
+ {!fpvIcao24 && ( +
+ +
+ )} - + )} -
- -
+ {!fpvIcao24 && ( +
+ +
+ )} -
- - - -
+ {!fpvIcao24 && ( +
+ + + +
+ )}
- setShowHelp(false)} - /> + {!fpvIcao24 && ( + setShowHelp(false)} + /> + )} + + + {fpvIcao24 && fpvFlightOrCached && ( + + )} +
); } diff --git a/src/components/map/camera-controller-utils.ts b/src/components/map/camera-controller-utils.ts new file mode 100644 index 0000000..eb2dcf2 --- /dev/null +++ b/src/components/map/camera-controller-utils.ts @@ -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(); +} diff --git a/src/components/map/camera-controller.tsx b/src/components/map/camera-controller.tsx index 7687a16..2900120 100644 --- a/src/components/map/camera-controller.tsx +++ b/src/components/map/camera-controller.tsx @@ -1,15 +1,37 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type MutableRefObject } from "react"; 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 type { City } from "@/lib/cities"; +import type { FlightState } from "@/lib/opensky"; const IDLE_TIMEOUT_MS = 5_000; const ORBIT_EASE_IN_MS = 2000; const DEFAULT_ZOOM = 9.2; const DEFAULT_PITCH = 49; 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_DECEL = 4.0; @@ -26,13 +48,39 @@ type ActionState = { 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; +}) { const { map, isLoaded } = useMap(); const { settings } = useSettings(); const prevCityRef = useRef(null); + const prevFollowRef = useRef(null); + const prevFpvRef = useRef(null); const idleTimerRef = useRef | null>(null); const orbitFrameRef = useRef(null); const isInteractingRef = useRef(false); + const isFollowingRef = useRef(false); + const isFpvActiveRef = useRef(false); + const fpvFlightRef = useRef(fpvFlight); + const fpvPosRef = useRef(fpvPositionRef); + + useEffect(() => { + fpvPosRef.current = fpvPositionRef; + }, [fpvPositionRef]); + + useEffect(() => { + fpvFlightRef.current = fpvFlight; + }, [fpvFlight]); useEffect(() => { if (!map || !isLoaded || !city) return; @@ -49,10 +97,300 @@ export function CameraController({ city }: { city: 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 | 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(() => { if (!map || !isLoaded || !city) return; const onNorthUp = () => { + if (isFpvActiveRef.current) return; map.easeTo({ bearing: 0, duration: 650, @@ -61,6 +399,7 @@ export function CameraController({ city }: { city: City }) { }; const onResetView = (event: Event) => { + if (isFpvActiveRef.current) return; const customEvent = event as CustomEvent<{ center?: [number, number] }>; const center = customEvent.detail?.center ?? city.coordinates; map.flyTo({ @@ -167,6 +506,7 @@ export function CameraController({ city }: { city: City }) { } const onStart = (e: Event) => { + if (isFpvActiveRef.current) return; const { type, direction } = (e as CustomEvent).detail as { type: CameraActionType; direction: number; @@ -201,7 +541,14 @@ export function CameraController({ city }: { city: City }) { }, [map, isLoaded]); useEffect(() => { - if (!map || !isLoaded || !city || !settings.autoOrbit) { + if ( + !map || + !isLoaded || + !city || + !settings.autoOrbit || + followFlight || + fpvFlight + ) { if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); return; @@ -224,7 +571,7 @@ export function CameraController({ city }: { city: City }) { if (!map || isInteractingRef.current) return; const resumeElapsed = performance.now() - resumeStart; 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; map.setBearing(bearing % 360); orbitFrameRef.current = requestAnimationFrame(tick); @@ -290,6 +637,8 @@ export function CameraController({ city }: { city: City }) { map, isLoaded, city, + followFlight, + fpvFlight, settings.autoOrbit, settings.orbitSpeed, settings.orbitDirection, diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx index 3573f30..9663529 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, type MutableRefObject } from "react"; import maplibregl from "maplibre-gl"; import { MapboxOverlay } from "@deck.gl/mapbox"; 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 { PickingInfo } from "@deck.gl/core"; -/** Typed overlay with deck.gl's pickObject capability */ type DeckGLOverlay = MapboxOverlay & { pickObject?(opts: { x: number; @@ -192,8 +191,9 @@ function getRingUrl(): string { function buildStartupFallbackTrail(f: FlightState): [number, number][] { if (f.longitude == null || f.latitude == null) return []; - const heading = ((f.trueTrack ?? 0) * Math.PI) / 180; - const speed = f.velocity ?? 200; + const heading = + ((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 path: [number, number][] = []; @@ -448,6 +448,13 @@ type FlightLayerProps = { trailDistance: number; showShadows: boolean; showAltitudeColors: boolean; + fpvIcao24?: string | null; + fpvPositionRef?: MutableRefObject<{ + lng: number; + lat: number; + alt: number; + track: number; + } | null>; }; export function FlightLayers({ @@ -460,6 +467,8 @@ export function FlightLayers({ trailDistance, showShadows, showAltitudeColors, + fpvIcao24 = null, + fpvPositionRef, }: FlightLayerProps) { const { map, isLoaded } = useMap(); const overlayRef = useRef(null); @@ -481,6 +490,8 @@ export function FlightLayers({ const showShadowsRef = useRef(showShadows); const showAltColorsRef = useRef(showAltitudeColors); const selectedIcao24Ref = useRef(selectedIcao24); + const fpvIcao24Ref = useRef(fpvIcao24); + const fpvPosRef = useRef(fpvPositionRef); const prevSelectedRef = useRef(null); const selectionChangeTimeRef = useRef(0); const SELECTION_FADE_MS = 600; @@ -493,6 +504,8 @@ export function FlightLayers({ trailDistanceRef.current = trailDistance; showShadowsRef.current = showShadows; showAltColorsRef.current = showAltitudeColors; + fpvIcao24Ref.current = fpvIcao24; + fpvPosRef.current = fpvPositionRef; if (selectedIcao24 !== selectedIcao24Ref.current) { prevSelectedRef.current = selectedIcao24Ref.current; selectionChangeTimeRef.current = performance.now(); @@ -507,6 +520,8 @@ export function FlightLayers({ showShadows, showAltitudeColors, selectedIcao24, + fpvIcao24, + fpvPositionRef, ]); useEffect(() => { @@ -544,11 +559,12 @@ export function FlightLayers({ for (const f of flights) { if (f.longitude != null && f.latitude != null) { 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, { lng: f.longitude, lat: f.latitude, - alt: f.baroAltitude ?? 0, + alt: rawAlt, track: prev != null ? lerpAngle(prev.track, rawTrack, TRACK_DAMPING) @@ -576,7 +592,6 @@ export function FlightLayers({ [map], ); - // Reset cursor if component unmounts while hovering. useEffect(() => { return () => { const canvas = map?.getCanvas(); @@ -674,7 +689,7 @@ export function FlightLayers({ let prev = prevSnapshotsRef.current.get(f.icao24); if (!prev) { 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( (spd * (animDurationRef.current / 1000)) / 111_320, 0.015, @@ -705,7 +720,7 @@ export function FlightLayers({ } 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 extraDeg = Math.min((speed * extraSec) / 111_320, 0.03); const moveDx = Math.sin(heading) * extraDeg; @@ -724,6 +739,33 @@ export function FlightLayers({ 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(); for (const f of interpolated) { const curr = currSnapshotsRef.current.get(f.icao24); @@ -771,8 +813,10 @@ export function FlightLayers({ })() : 0; - const speed = f.velocity ?? 0; - const verticalRate = f.verticalRate ?? 0; + const speed = Number.isFinite(f.velocity) ? f.velocity! : 0; + const verticalRate = Number.isFinite(f.verticalRate) + ? f.verticalRate! + : 0; const kinematicPitch = speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0; @@ -789,12 +833,13 @@ export function FlightLayers({ layers.push( new IconLayer({ id: "flight-shadows", - data: interpolated, + data: visibleFlights, getPosition: (d) => [d.longitude!, d.latitude!, 0], getIcon: () => "aircraft", getSize: (d) => 20 * categorySizeMultiplier(d.category), - getColor: [0, 0, 0, 60], - getAngle: (d) => 360 - (d.trueTrack ?? 0), + getColor: () => [0, 0, 0, 60], + getAngle: (d) => + 360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0), iconAtlas: atlasUrl, iconMapping: AIRCRAFT_ICON_MAPPING, billboard: false, @@ -942,6 +987,7 @@ export function FlightLayers({ const animFlight = interpolatedMap.get(d.icao24); const visiblePoints = getVisibleTrailPoints(d, animFlight); const len = visiblePoints.length; + return visiblePoints.map((point, i) => { const tVal = len > 1 ? i / (len - 1) : 1; const fade = Math.pow(tVal, 1.65); @@ -1058,7 +1104,7 @@ export function FlightLayers({ layers.push( new ScenegraphLayer({ id: "flight-aircraft", - data: interpolated, + data: visibleFlights, getPosition: (d) => [ d.longitude!, d.latitude!, @@ -1066,7 +1112,7 @@ export function FlightLayers({ ], getOrientation: (d) => { 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]; }, getColor: (d) => { diff --git a/src/components/ui/airport-search-input.tsx b/src/components/ui/airport-search-input.tsx new file mode 100644 index 0000000..7f96b38 --- /dev/null +++ b/src/components/ui/airport-search-input.tsx @@ -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(null); + const containerRef = useRef(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, + }; + } + + 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 ( +
+ {selected && !isOpen ? ( + + )} + + ) : ( +
+ + { + 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 && ( + + )} +
+ )} + + + {isOpen && ( + + +
+ {!hasResults && ( +

+ No airports found +

+ )} + + {featured.length > 0 && ( + <> + {query && ( +

+ Featured +

+ )} + {featured.map((city) => ( + handleSelectCity(city)} + /> + ))} + + )} + + {airports.length > 0 && ( + <> +

0 ? "pt-2" : "pt-1.5" + }`} + > + Airports +

+ {airports.map((airport) => ( + handleSelect(airport)} + /> + ))} + + )} +
+
+
+ )} +
+
+ ); +} + +function DropdownRow({ + name, + detail, + isActive, + onClick, +}: { + name: string; + detail: string; + isActive: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index c837721..494f094 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -17,6 +17,9 @@ import { Palette, ArrowLeftRight, Github, + Plane, + Eye, + Loader2, } from "lucide-react"; import { CITIES, type City } from "@/lib/cities"; 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 { ScrollArea } from "@/components/ui/scroll-area"; import { Slider } from "@/components/ui/slider"; +import type { FlightState } from "@/lib/opensky"; +import { formatCallsign } from "@/lib/flight-utils"; type TabId = "search" | "style" | "settings"; @@ -38,6 +43,9 @@ type ControlPanelProps = { onSelectCity: (city: City) => void; activeStyle: MapStyle; onSelectStyle: (style: MapStyle) => void; + flights: FlightState[]; + activeFlightIcao24: string | null; + onLookupFlight: (query: string, enterFpv?: boolean) => Promise; }; export function ControlPanel({ @@ -45,6 +53,9 @@ export function ControlPanel({ onSelectCity, activeStyle, onSelectStyle, + flights, + activeFlightIcao24, + onLookupFlight, }: ControlPanelProps) { const [openTab, setOpenTab] = useState(null); @@ -94,6 +105,9 @@ export function ControlPanel({ }} activeStyle={activeStyle} onSelectStyle={onSelectStyle} + flights={flights} + activeFlightIcao24={activeFlightIcao24} + onLookupFlight={onLookupFlight} /> )} @@ -109,6 +123,9 @@ function PanelDialog({ onSelectCity, activeStyle, onSelectStyle, + flights, + activeFlightIcao24, + onLookupFlight, }: { activeTab: TabId; onTabChange: (tab: TabId) => void; @@ -117,6 +134,9 @@ function PanelDialog({ onSelectCity: (city: City) => void; activeStyle: MapStyle; onSelectStyle: (style: MapStyle) => void; + flights: FlightState[]; + activeFlightIcao24: string | null; + onLookupFlight: (query: string, enterFpv?: boolean) => Promise; }) { const dialogRef = useRef(null); @@ -246,7 +266,7 @@ function PanelDialog({

- v0.1 \u00b7 OpenSky Network + v0.1 · OpenSky Network

@@ -288,6 +308,13 @@ function PanelDialog({ { + const found = await onLookupFlight(query, enterFpv); + if (found) onClose(); + return found; + }} /> )} @@ -374,11 +401,19 @@ function TabContent({ children }: { children: ReactNode }) { function SearchContent({ activeCity, onSelect, + flights, + activeFlightIcao24, + onLookupFlight, }: { activeCity: City; onSelect: (city: City) => void; + flights: FlightState[]; + activeFlightIcao24: string | null; + onLookupFlight: (query: string, enterFpv?: boolean) => Promise; }) { const [query, setQuery] = useState(""); + const [lookupBusy, setLookupBusy] = useState(false); + const [lookupError, setLookupError] = useState(null); const inputRef = useRef(null); useEffect(() => { @@ -409,7 +444,58 @@ function SearchContent({ return { featured, airports }; }, [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 (
@@ -418,9 +504,18 @@ function SearchContent({ setQuery(e.target.value)} - placeholder="Search airports..." - aria-label="Search airports by name, IATA code, city, or country" + onChange={(e) => { + setQuery(e.target.value); + 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" /> {query && ( @@ -436,9 +531,64 @@ function SearchContent({
+ {compactQuery && ( +
+ + +
+ )} + + {lookupError && ( +

+ {lookupError} +

+ )} + + {flightMatches.length > 0 && ( + <> +

+ Flights +

+ {flightMatches.map((flight) => ( + void openFlight(flight.icao24, false)} + onFpv={() => void openFlight(flight.icao24, true)} + /> + ))} + + )} + {!hasResults && (

- No airports found + No airports or flights found

)} @@ -453,7 +603,7 @@ function SearchContent({ onSelect(city)} /> @@ -474,7 +624,7 @@ function SearchContent({ 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 ( +
+ + +
+ ); +} + function StyleContent({ activeStyle, onSelect, @@ -546,8 +742,7 @@ function StyleContent({

- Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base - maps \u00a9 CARTO + Satellite © Esri · Terrain © OpenTopoMap · Base maps © CARTO

@@ -594,6 +789,7 @@ function StyleTile({ fill unoptimized onLoad={() => setImgLoaded(true)} + onError={() => setImgLoaded(true)} className={`object-cover transition-all duration-500 group-hover:scale-105 ${ imgLoaded ? "opacity-100" : "opacity-0" }`} @@ -747,7 +943,7 @@ function OrbitSpeedSlider({ const activeLabel = ORBIT_SPEED_PRESETS.find( (p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD, - )?.label ?? `${value.toFixed(2)}\u00d7`; + )?.label ?? `${value.toFixed(2)}×`; function handleChange(vals: number[]) { let raw = vals[0]; diff --git a/src/components/ui/flight-card.tsx b/src/components/ui/flight-card.tsx index 20bd1c6..dd311f5 100644 --- a/src/components/ui/flight-card.tsx +++ b/src/components/ui/flight-card.tsx @@ -13,6 +13,7 @@ import { X, Navigation, Building2, + Eye, } from "lucide-react"; import type { FlightState } from "@/lib/opensky"; import { @@ -28,11 +29,18 @@ import { airlineLogoCandidates } from "@/lib/airline-logos"; type FlightCardProps = { flight: FlightState | null; onClose: () => void; + onToggleFpv?: (icao24: string) => void; + isFpvActive?: boolean; }; const loadedLogoUrls = new Set(); -export function FlightCard({ flight, onClose }: FlightCardProps) { +export function FlightCard({ + flight, + onClose, + onToggleFpv, + isFpvActive = false, +}: FlightCardProps) { const airline = flight ? lookupAirline(flight.callsign) : null; const flightNum = flight ? parseFlightNumber(flight.callsign) : null; const company = @@ -41,6 +49,11 @@ export function FlightCard({ flight, onClose }: FlightCardProps) { const logoCandidates = airlineLogoCandidates(airline); const heading = flight?.trueTrack ?? null; const cardinal = heading !== null ? headingToCardinal(heading) : null; + const canEnterFpv = + flight != null && + flight.longitude != null && + flight.latitude != null && + !flight.onGround; const [logoIndexByAirline, setLogoIndexByAirline] = useState< Record >({}); @@ -132,15 +145,56 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {

- - - +
+ {onToggleFpv && ( + + (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)" + } + > + + + )} + + + +
{company && ( @@ -172,14 +226,17 @@ export function FlightCard({ flight, onClose }: FlightCardProps) { icon={} label="Heading" value={ - heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—" + heading !== null && Number.isFinite(heading) + ? `${Math.round(heading)}° ${cardinal}` + : "—" } /> } label="V/S" value={ - flight.verticalRate !== null + flight.verticalRate !== null && + Number.isFinite(flight.verticalRate) ? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)} m/s` : "—" } @@ -201,20 +258,25 @@ export function FlightCard({ flight, onClose }: FlightCardProps) { className="h-3 w-3 text-white/25" style={{ transform: - heading !== null ? `rotate(${heading}deg)` : undefined, + heading !== null && Number.isFinite(heading) + ? `rotate(${heading}deg)` + : undefined, }} />

Heading {cardinal} - {flight.latitude !== null && flight.longitude !== null && ( - - {" "} - · {Math.abs(flight.latitude).toFixed(2)}° - {flight.latitude >= 0 ? "N" : "S"},{" "} - {Math.abs(flight.longitude).toFixed(2)}° - {flight.longitude >= 0 ? "E" : "W"} - - )} + {flight.latitude !== null && + flight.longitude !== null && + Number.isFinite(flight.latitude) && + Number.isFinite(flight.longitude) && ( + + {" "} + · {Math.abs(flight.latitude).toFixed(2)}° + {flight.latitude >= 0 ? "N" : "S"},{" "} + {Math.abs(flight.longitude).toFixed(2)}° + {flight.longitude >= 0 ? "E" : "W"} + + )}

)} diff --git a/src/components/ui/fpv-hud.tsx b/src/components/ui/fpv-hud.tsx new file mode 100644 index 0000000..cbb7676 --- /dev/null +++ b/src/components/ui/fpv-hud.tsx @@ -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 = { + 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(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 ( + + ); +} + +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(null); + const logoError = logoUrl !== null && logoUrl === logoErrorUrl; + const vsIcon = + vs !== null && Number.isFinite(vs) ? ( + vs > 0.5 ? ( + + ) : vs < -0.5 ? ( + + ) : ( + + ) + ) : null; + + return ( + +
+
+
+ +
+

+ {heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—"} +

+
+ +
+
+ {logoUrl && !logoError ? ( + + {airline setLogoErrorUrl(logoUrl)} + /> + + ) : ( + + + + )} +
+

+ {formatCallsign(flight.callsign)} +

+

+ {airline ?? flight.originCountry} +

+
+
+ +
+
+ + + ALT + +
+

+ {altFeet !== null ? altFeet.toLocaleString() : "—"} +

+

ft

+
+ +
+
+ + + SPD + +
+

+ {speedKts ?? "—"} +

+

kts

+
+ +
+
+ {vsIcon ?? } + + V/S + +
+

0.5 + ? "text-emerald-400/80" + : vs !== null && vs < -0.5 + ? "text-amber-400/80" + : "text-white/90" + }`} + > + {vsDisplay ?? "—"} +

+

fpm

+
+ + +
+
+
+ ); +} diff --git a/src/components/ui/keyboard-shortcuts-help.tsx b/src/components/ui/keyboard-shortcuts-help.tsx index 2482c61..7b61436 100644 --- a/src/components/ui/keyboard-shortcuts-help.tsx +++ b/src/components/ui/keyboard-shortcuts-help.tsx @@ -9,6 +9,7 @@ const SHORTCUTS = [ { key: "R", description: "Reset view" }, { key: "O", description: "Toggle orbit" }, { key: "/", description: "Open search" }, + { key: "F", description: "First person view" }, { key: "?", description: "Shortcuts help" }, { key: "Esc", description: "Close / Deselect" }, ] as const; diff --git a/src/components/ui/map-attribution.tsx b/src/components/ui/map-attribution.tsx index ea90ec4..42e8f7f 100644 --- a/src/components/ui/map-attribution.tsx +++ b/src/components/ui/map-attribution.tsx @@ -11,18 +11,17 @@ type MapAttributionProps = { const SM_BREAKPOINT = 640; -function getInitialExpanded(): boolean { - if (typeof window === "undefined") return true; - return window.innerWidth >= SM_BREAKPOINT; -} - export function MapAttribution({ styleId }: MapAttributionProps) { - const [expanded, setExpanded] = useState(getInitialExpanded); + const [expanded, setExpanded] = useState(false); const attributions = getAttributions(styleId); const containerRef = useRef(null); const toggle = useCallback(() => setExpanded((prev) => !prev), []); + useEffect(() => { + setExpanded(window.innerWidth >= SM_BREAKPOINT); + }, []); + // Close on outside click for small screens useEffect(() => { if (!expanded) return; diff --git a/src/hooks/use-flights.ts b/src/hooks/use-flights.ts index 2da2617..61f0d86 100644 --- a/src/hooks/use-flights.ts +++ b/src/hooks/use-flights.ts @@ -19,6 +19,7 @@ const CREDIT_TIER_EMERGENCY = 200; const RATE_LIMIT_BACKOFF_MS = 30_000; const VISIBILITY_RESUME_STALE_MS = 60_000; +const FPV_BBOX_RADIUS = 2; function adaptiveInterval(creditsRemaining: number | null): number { if (creditsRemaining === null) return BASE_POLL_MS; @@ -28,7 +29,15 @@ function adaptiveInterval(creditsRemaining: number | null): number { 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([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -42,6 +51,32 @@ export function useFlights(city: City | null) { const creditsRef = useRef(null); 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(fpvIcao24); + const fpvSeedRef = useRef(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(() => { if (countdownRef.current) { @@ -75,10 +110,11 @@ export function useFlights(city: City | null) { const scheduleNext = useCallback( (target: City, delayMs: number) => { 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( @@ -90,18 +126,50 @@ export function useFlights(city: City | null) { try { setLoading(true); setError(null); + let bbox: [number, number, number, number]; + const inFpv = fpvIcao24Ref.current !== null; + + if (inFpv && fpvCenterRef.current) { + bbox = bboxFromCenter( + fpvCenterRef.current.lng, + fpvCenterRef.current.lat, + FPV_BBOX_RADIUS, + ); + } else if (inFpv && fpvSeedCenterRef.current) { + fpvCenterRef.current = fpvSeedCenterRef.current; + bbox = bboxFromCenter( + fpvSeedCenterRef.current.lng, + fpvSeedCenterRef.current.lat, + FPV_BBOX_RADIUS, + ); + } else if (inFpv) { + fpvCenterRef.current = { + lng: target.coordinates[0], + lat: target.coordinates[1], + }; + bbox = bboxFromCenter( + target.coordinates[0], + target.coordinates[1], + FPV_BBOX_RADIUS, + ); + } else { + bbox = bboxFromCenter( + target.coordinates[0], + target.coordinates[1], + target.radius, + ); + } - const bbox = bboxFromCenter( - target.coordinates[0], - target.coordinates[1], - target.radius, - ); const result = await fetchFlightsByBbox(...bbox, controller.signal); if (result.rateLimited) { + const retryDelayMs = + result.retryAfterSeconds && result.retryAfterSeconds > 0 + ? result.retryAfterSeconds * 1000 + : RATE_LIMIT_BACKOFF_MS; setRateLimited(true); - startCountdown(RATE_LIMIT_BACKOFF_MS); - scheduleNext(target, RATE_LIMIT_BACKOFF_MS); + startCountdown(retryDelayMs); + scheduleNext(target, retryDelayMs); return; } @@ -109,6 +177,17 @@ export function useFlights(city: City | null) { clearCountdown(); setFlights(result.flights); 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) { creditsRef.current = result.creditsRemaining; @@ -121,7 +200,6 @@ export function useFlights(city: City | null) { const isAbort = err instanceof Error && err.name === "AbortError"; if (isAbort) return; setError(err instanceof Error ? err.message : "Unknown error"); - setFlights([]); scheduleNext(target, RATE_LIMIT_BACKOFF_MS); } finally { setLoading(false); @@ -130,26 +208,30 @@ export function useFlights(city: City | null) { [scheduleNext, startCountdown, clearCountdown], ); + useEffect(() => { + fetchDataRef.current = (target: City) => { + void fetchData(target); + }; + }, [fetchData]); + useEffect(() => { if (!city) return; const activeCity = city; function onVisibilityChange() { - if (document.visibilityState === "visible") { - const elapsed = Date.now() - lastFetchRef.current; + if (document.visibilityState !== "visible") return; - if (elapsed >= VISIBILITY_RESUME_STALE_MS) { - clearSchedule(); - fetchData(activeCity); - } else { - const interval = adaptiveInterval(creditsRef.current); - const remaining = Math.max(1_000, interval - elapsed); - clearSchedule(); - scheduleNext(activeCity, remaining); - } - } else { + const elapsed = Date.now() - lastFetchRef.current; + + if (elapsed >= VISIBILITY_RESUME_STALE_MS) { clearSchedule(); + fetchData(activeCity); + } else { + const interval = adaptiveInterval(creditsRef.current); + const remaining = Math.max(1_000, interval - elapsed); + clearSchedule(); + scheduleNext(activeCity, remaining); } } @@ -160,6 +242,8 @@ export function useFlights(city: City | null) { }, [city, fetchData, scheduleNext, clearSchedule]); useEffect(() => { + if (fpvIcao24Ref.current !== null) return; + clearSchedule(); if (!city) { @@ -182,5 +266,29 @@ export function useFlights(city: City | null) { }; }, [city, fetchData, clearCountdown, clearSchedule]); + const prevFpvRef = useRef(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 }; } diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index c6750d3..1ab34a7 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -9,6 +9,8 @@ type ShortcutActions = { onOpenSearch: () => void; onToggleHelp: () => void; onDeselect: () => void; + onToggleFpv: () => void; + isFpv?: boolean; }; const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); @@ -40,6 +42,14 @@ export function useKeyboardShortcuts(actions: ShortcutActions) { if (dialogOpen) return; + if (a.isFpv) { + if (e.key === "f" || e.key === "F") { + e.preventDefault(); + a.onToggleFpv(); + } + return; + } + switch (e.key) { case "n": case "N": @@ -60,6 +70,11 @@ export function useKeyboardShortcuts(actions: ShortcutActions) { e.preventDefault(); a.onOpenSearch(); break; + case "f": + case "F": + e.preventDefault(); + a.onToggleFpv(); + break; case "?": e.preventDefault(); a.onToggleHelp(); diff --git a/src/hooks/use-settings.tsx b/src/hooks/use-settings.tsx index 53a532b..6d19eee 100644 --- a/src/hooks/use-settings.tsx +++ b/src/hooks/use-settings.tsx @@ -22,12 +22,15 @@ export type Settings = { trailDistance: number; showShadows: boolean; showAltitudeColors: boolean; + fpvChaseDistance: number; }; const TRAIL_THICKNESS_MIN = 1; const TRAIL_THICKNESS_MAX = 8; const TRAIL_DISTANCE_MIN = 12; 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 { return Math.max(min, Math.min(max, value)); @@ -45,6 +48,11 @@ function normalizeSettings(input: Settings): Settings { trailDistance: Math.round( 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, showShadows: true, showAltitudeColors: true, + fpvChaseDistance: 0.0048, }; const STORAGE_KEY = "aeris:settings"; @@ -68,7 +77,6 @@ type StorageEnvelope = { data: Settings; }; -/** Validate that a parsed value matches the Settings shape. */ function isValidSettings(obj: unknown): obj is Settings { if (typeof obj !== "object" || obj === null) return false; const s = obj as Record; @@ -87,7 +95,11 @@ function isValidSettings(obj: unknown): obj is Settings { s.trailDistance >= TRAIL_DISTANCE_MIN && s.trailDistance <= TRAIL_DISTANCE_MAX && 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 }; localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope)); } catch { - /* quota exceeded or blocked */ + /* noop */ } } diff --git a/src/lib/airports.ts b/src/lib/airports.ts index 4e4d396..821f07b 100644 --- a/src/lib/airports.ts +++ b/src/lib/airports.ts @@ -72607,3 +72607,24 @@ export function airportToCity(airport: Airport): City { 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; +} diff --git a/src/lib/flight-utils.ts b/src/lib/flight-utils.ts index cb4596d..b2d4105 100644 --- a/src/lib/flight-utils.ts +++ b/src/lib/flight-utils.ts @@ -26,7 +26,7 @@ function lerpColor(a: RGB, b: RGB, t: number): RGB { export function altitudeToColor( altitude: number | null, ): [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 t = Math.pow(normalized, 0.4); @@ -46,17 +46,17 @@ export function altitudeToColor( } 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); } 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`; } 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`; } @@ -66,8 +66,8 @@ export function formatCallsign(callsign: string | 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 index = Math.round(degrees / 45) % 8; + const index = ((Math.round(degrees / 45) % 8) + 8) % 8; return directions[index]; } diff --git a/src/lib/opensky.ts b/src/lib/opensky.ts index f9f21b2..84a161d 100644 --- a/src/lib/opensky.ts +++ b/src/lib/opensky.ts @@ -2,6 +2,8 @@ const OPENSKY_API = "https://opensky-network.org/api"; const FETCH_TIMEOUT_MS = 15_000; +const ICAO24_REGEX = /^[0-9a-f]{6}$/i; +const CALLSIGN_CACHE_TTL_MS = 30_000; export type FlightState = { icao24: string; @@ -26,45 +28,114 @@ type OpenSkyResponse = { states: (string | number | boolean | null)[][] | null; }; -function parseStates(raw: OpenSkyResponse): FlightState[] { - if (!raw.states) return []; +type ParseStateOptions = { + 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 - .map((s) => ({ - icao24: s[0] as string, - 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, - })) + .map(parseStateRow) + .filter((state): state is FlightState => state !== null) .filter( (f) => f.longitude !== null && f.latitude !== null && - !f.onGround && - f.baroAltitude !== null, + (includeGround || !f.onGround) && + (!requireBaroAltitude || f.baroAltitude !== null), ); } +function normalizeCallsign(value: string | null): string { + if (!value) return ""; + return value.trim().toUpperCase().replace(/\s+/g, ""); +} + export type FetchResult = { flights: FlightState[]; rateLimited: boolean; 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( lamin: number, lamax: number, @@ -72,10 +143,8 @@ export async function fetchFlightsByBbox( lomax: number, signal?: AbortSignal, ): Promise { - const la0 = clamp(lamin, -90, 90); - const la1 = clamp(lamax, -90, 90); - const lo0 = clamp(lomin, -180, 180); - const lo1 = clamp(lomax, -180, 180); + const [la0, la1] = normalizeBounds(lamin, lamax, -90, 90); + const [lo0, lo1] = normalizeBounds(lomin, lomax, -180, 180); 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", signal: controller.signal, }); + const rateLimitInfo = parseRateLimitInfo(res); if (res.status === 429) { - return { flights: [], rateLimited: true, creditsRemaining: null }; + return { + flights: [], + rateLimited: true, + creditsRemaining: rateLimitInfo.creditsRemaining, + retryAfterSeconds: rateLimitInfo.retryAfterSeconds, + }; } 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 creditsRaw = res.headers.get("x-rate-limit-remaining"); - const creditsRemaining = - creditsRaw !== null ? parseInt(creditsRaw, 10) : null; + const payload = (await res.json()) as unknown; + const data = + typeof payload === "object" && payload !== null + ? (payload as OpenSkyResponse) + : { time: 0, states: null }; return { flights: parseStates(data), rateLimited: false, - creditsRemaining: Number.isNaN(creditsRemaining) - ? null - : creditsRemaining, + creditsRemaining: rateLimitInfo.creditsRemaining, + retryAfterSeconds: null, }; } catch (err) { if (err instanceof Error && err.name === "AbortError") { @@ -129,3 +209,251 @@ export function bboxFromCenter( ): [lamin: number, lamax: number, lomin: number, lomax: number] { 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 { + 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 { + if (segments.length === 0) { + return { + flights: [], + rateLimited: false, + creditsRemaining: null, + retryAfterSeconds: null, + }; + } + + const seen = new Map(); + 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((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, + }; +}