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:
@ -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];
|
||||
|
||||
Reference in New Issue
Block a user