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

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

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

* Refactor CameraController and ControlPanel components; enhance flight search functionality

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

* feat: enhance FPV functionality and improve flight data handling

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

View File

@ -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<boolean>;
};
export function ControlPanel({
@ -45,6 +53,9 @@ export function ControlPanel({
onSelectCity,
activeStyle,
onSelectStyle,
flights,
activeFlightIcao24,
onLookupFlight,
}: ControlPanelProps) {
const [openTab, setOpenTab] = useState<TabId | null>(null);
@ -94,6 +105,9 @@ export function ControlPanel({
}}
activeStyle={activeStyle}
onSelectStyle={onSelectStyle}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={onLookupFlight}
/>
)}
</AnimatePresence>
@ -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<boolean>;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
@ -246,7 +266,7 @@ function PanelDialog({
</a>
<div className="border-t border-white/3 pt-2 px-2.5">
<p className="text-[10px] font-medium text-white/10 tracking-wide">
v0.1 \u00b7 OpenSky Network
v0.1 · OpenSky Network
</p>
</div>
</div>
@ -288,6 +308,13 @@ function PanelDialog({
<SearchContent
activeCity={activeCity}
onSelect={onSelectCity}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={async (query, enterFpv = false) => {
const found = await onLookupFlight(query, enterFpv);
if (found) onClose();
return found;
}}
/>
</TabContent>
)}
@ -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<boolean>;
}) {
const [query, setQuery] = useState("");
const [lookupBusy, setLookupBusy] = useState(false);
const [lookupError, setLookupError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex h-full flex-col">
@ -418,9 +504,18 @@ function SearchContent({
<input
ref={inputRef}
value={query}
onChange={(e) => 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({
<ScrollArea className="flex-1">
<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 && (
<p className="py-8 text-center text-[12px] text-white/25">
No airports found
No airports or flights found
</p>
)}
@ -453,7 +603,7 @@ function SearchContent({
<LocationRow
key={city.id}
name={city.name}
detail={`${city.iata} \u00b7 ${city.country}`}
detail={`${city.iata} · ${city.country}`}
isActive={activeCity?.id === city.id}
onClick={() => onSelect(city)}
/>
@ -474,7 +624,7 @@ function SearchContent({
<LocationRow
key={airport.iata}
name={airport.name}
detail={`${airport.iata} \u00b7 ${airport.city}, ${airport.country}`}
detail={`${airport.iata} · ${airport.city}, ${airport.country}`}
isActive={activeCity?.iata === airport.iata}
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({
activeStyle,
onSelect,
@ -546,8 +742,7 @@ function StyleContent({
</div>
<div className="border-t border-white/4 px-5 py-3">
<p className="text-[11px] font-medium text-white/12">
Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base
maps \u00a9 CARTO
Satellite © Esri · Terrain © OpenTopoMap · Base maps © CARTO
</p>
</div>
</ScrollArea>
@ -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];