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:
257
src/components/ui/airport-search-input.tsx
Normal file
257
src/components/ui/airport-search-input.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Search, X, MapPin, ChevronRight } from "lucide-react";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import { searchAirports, type Airport } from "@/lib/airports";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
type AirportSearchInputProps = {
|
||||
placeholder?: string;
|
||||
selected: Airport | null;
|
||||
onSelect: (airport: Airport) => void;
|
||||
onClear?: () => void;
|
||||
autoFocus?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function AirportSearchInput({
|
||||
placeholder = "Search airports...",
|
||||
selected,
|
||||
onSelect,
|
||||
onClear,
|
||||
autoFocus = false,
|
||||
label = "Search airports",
|
||||
}: AirportSearchInputProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const { featured, airports } = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (!q) {
|
||||
return {
|
||||
featured: CITIES.slice(0, 10),
|
||||
airports: [] as ReturnType<typeof searchAirports>,
|
||||
};
|
||||
}
|
||||
|
||||
const featured = CITIES.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.iata.toLowerCase().includes(q) ||
|
||||
c.country.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const featuredIatas = new Set(CITIES.map((c) => c.iata));
|
||||
const airports = searchAirports(q, 15).filter(
|
||||
(a) => !featuredIatas.has(a.iata),
|
||||
);
|
||||
|
||||
return { featured, airports };
|
||||
}, [query]);
|
||||
|
||||
const hasResults = featured.length > 0 || airports.length > 0;
|
||||
|
||||
function handleSelect(airport: Airport) {
|
||||
onSelect(airport);
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleSelectCity(city: City) {
|
||||
const real = searchAirports(city.iata, 1).find((a) => a.iata === city.iata);
|
||||
const airport: Airport = real ?? {
|
||||
iata: city.iata,
|
||||
name: city.name,
|
||||
city: city.name,
|
||||
country: city.country,
|
||||
lat: city.coordinates[1],
|
||||
lng: city.coordinates[0],
|
||||
};
|
||||
handleSelect(airport);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setQuery("");
|
||||
onClear?.();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{selected && !isOpen ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2.5 text-left transition-colors hover:bg-white/6"
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-white/8">
|
||||
<MapPin className="h-3 w-3 text-white/50" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[13px] font-semibold text-white/80">
|
||||
{selected.iata}
|
||||
</span>
|
||||
<span className="ml-1.5 text-[11px] text-white/30">
|
||||
{selected.city}
|
||||
</span>
|
||||
</div>
|
||||
{onClear && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-white/25" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
className="flex-1 bg-transparent text-[13px] font-medium text-white/90 placeholder:text-white/20 outline-none"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery("")}
|
||||
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-white/8 bg-[#0c0c0e]/95 shadow-[0_20px_60px_rgba(0,0,0,.7)] backdrop-blur-2xl"
|
||||
>
|
||||
<ScrollArea className="max-h-56">
|
||||
<div className="p-1.5">
|
||||
{!hasResults && (
|
||||
<p className="py-6 text-center text-[11px] text-white/25">
|
||||
No airports found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{featured.length > 0 && (
|
||||
<>
|
||||
{query && (
|
||||
<p className="px-2.5 pt-1.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-white/15">
|
||||
Featured
|
||||
</p>
|
||||
)}
|
||||
{featured.map((city) => (
|
||||
<DropdownRow
|
||||
key={city.id}
|
||||
name={city.name}
|
||||
detail={`${city.iata} · ${city.country}`}
|
||||
isActive={selected?.iata === city.iata}
|
||||
onClick={() => handleSelectCity(city)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{airports.length > 0 && (
|
||||
<>
|
||||
<p
|
||||
className={`px-2.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-white/15 ${
|
||||
featured.length > 0 ? "pt-2" : "pt-1.5"
|
||||
}`}
|
||||
>
|
||||
Airports
|
||||
</p>
|
||||
{airports.map((airport) => (
|
||||
<DropdownRow
|
||||
key={airport.iata}
|
||||
name={airport.name}
|
||||
detail={`${airport.iata} · ${airport.city}, ${airport.country}`}
|
||||
isActive={selected?.iata === airport.iata}
|
||||
onClick={() => handleSelect(airport)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownRow({
|
||||
name,
|
||||
detail,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
name: string;
|
||||
detail: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-white/5 ${
|
||||
isActive ? "bg-white/6" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-white/4">
|
||||
<MapPin className="h-3 w-3 text-white/35" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-[12px] font-medium text-white/75">{name}</p>
|
||||
<p className="text-[10px] text-white/25">{detail}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-white/10 group-hover:text-white/20" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,9 @@ import {
|
||||
Palette,
|
||||
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];
|
||||
|
||||
@ -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<string>();
|
||||
|
||||
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<string, number>
|
||||
>({});
|
||||
@ -132,15 +145,56 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
aria-label="Deselect flight"
|
||||
>
|
||||
<X className="h-3 w-3 text-white/40" />
|
||||
</motion.button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onToggleFpv && (
|
||||
<motion.button
|
||||
onClick={() =>
|
||||
(isFpvActive || canEnterFpv) &&
|
||||
flight &&
|
||||
onToggleFpv(flight.icao24)
|
||||
}
|
||||
disabled={!isFpvActive && !canEnterFpv}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition-colors ${
|
||||
isFpvActive
|
||||
? "bg-emerald-500/20 text-emerald-400"
|
||||
: !canEnterFpv
|
||||
? "bg-white/4 text-white/15 cursor-not-allowed"
|
||||
: "bg-white/6 text-white/40 hover:bg-white/12"
|
||||
}`}
|
||||
whileHover={
|
||||
isFpvActive || canEnterFpv ? { scale: 1.1 } : {}
|
||||
}
|
||||
whileTap={isFpvActive || canEnterFpv ? { scale: 0.9 } : {}}
|
||||
aria-label={
|
||||
isFpvActive
|
||||
? "Exit first person view"
|
||||
: canEnterFpv
|
||||
? "First person view"
|
||||
: "First person view unavailable"
|
||||
}
|
||||
title={
|
||||
isFpvActive
|
||||
? "Exit FPV (F)"
|
||||
: canEnterFpv
|
||||
? "First Person View (F)"
|
||||
: flight?.onGround
|
||||
? "FPV unavailable (aircraft on ground)"
|
||||
: "FPV unavailable (no position data)"
|
||||
}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</motion.button>
|
||||
)}
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
aria-label="Deselect flight"
|
||||
>
|
||||
<X className="h-3 w-3 text-white/40" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{company && (
|
||||
@ -172,14 +226,17 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
|
||||
icon={<Compass className="h-3 w-3" />}
|
||||
label="Heading"
|
||||
value={
|
||||
heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—"
|
||||
heading !== null && Number.isFinite(heading)
|
||||
? `${Math.round(heading)}° ${cardinal}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
icon={<ArrowDown className="h-3 w-3" />}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<p className="text-[11px] font-medium tracking-wide text-white/40">
|
||||
Heading {cardinal}
|
||||
{flight.latitude !== null && flight.longitude !== null && (
|
||||
<span className="text-white/20">
|
||||
{" "}
|
||||
· {Math.abs(flight.latitude).toFixed(2)}°
|
||||
{flight.latitude >= 0 ? "N" : "S"},{" "}
|
||||
{Math.abs(flight.longitude).toFixed(2)}°
|
||||
{flight.longitude >= 0 ? "E" : "W"}
|
||||
</span>
|
||||
)}
|
||||
{flight.latitude !== null &&
|
||||
flight.longitude !== null &&
|
||||
Number.isFinite(flight.latitude) &&
|
||||
Number.isFinite(flight.longitude) && (
|
||||
<span className="text-white/20">
|
||||
{" "}
|
||||
· {Math.abs(flight.latitude).toFixed(2)}°
|
||||
{flight.latitude >= 0 ? "N" : "S"},{" "}
|
||||
{Math.abs(flight.longitude).toFixed(2)}°
|
||||
{flight.longitude >= 0 ? "E" : "W"}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
272
src/components/ui/fpv-hud.tsx
Normal file
272
src/components/ui/fpv-hud.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "motion/react";
|
||||
import { X, Eye, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import { formatCallsign, headingToCardinal } from "@/lib/flight-utils";
|
||||
import { lookupAirline } from "@/lib/airlines";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
|
||||
type FpvHudProps = {
|
||||
flight: FlightState;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
const COMPASS_LABELS: Record<number, string> = {
|
||||
0: "N",
|
||||
45: "NE",
|
||||
90: "E",
|
||||
135: "SE",
|
||||
180: "S",
|
||||
225: "SW",
|
||||
270: "W",
|
||||
315: "NW",
|
||||
};
|
||||
|
||||
function CompassRibbon({ heading }: { heading: number | null }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
const w = 260;
|
||||
const h = 32;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const hdg = heading ?? 0;
|
||||
const cx = w / 2;
|
||||
const pxPerDeg = 2.2;
|
||||
for (let deg = -360; deg <= 720; deg += 5) {
|
||||
const normDeg = ((deg % 360) + 360) % 360;
|
||||
const offset = (((deg - hdg + 540) % 360) - 180) * pxPerDeg;
|
||||
const x = cx + offset;
|
||||
|
||||
if (x < -10 || x > w + 10) continue;
|
||||
|
||||
const isMajor = normDeg % 45 === 0;
|
||||
const isMinor = normDeg % 15 === 0;
|
||||
const isTiny = normDeg % 5 === 0;
|
||||
|
||||
if (isMajor) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, h - 1);
|
||||
ctx.lineTo(x, h - 10);
|
||||
ctx.stroke();
|
||||
|
||||
const label = COMPASS_LABELS[normDeg] ?? `${normDeg}`;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
||||
ctx.font = "bold 9px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(label, x, h - 14);
|
||||
} else if (isMinor) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, h - 1);
|
||||
ctx.lineTo(x, h - 7);
|
||||
ctx.stroke();
|
||||
} else if (isTiny) {
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.10)";
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, h - 1);
|
||||
ctx.lineTo(x, h - 4);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(56, 189, 248, 0.8)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 4, 0);
|
||||
ctx.lineTo(cx + 4, 0);
|
||||
ctx.lineTo(cx, 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(56, 189, 248, 0.4)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, 6);
|
||||
ctx.lineTo(cx, h);
|
||||
ctx.stroke();
|
||||
}, [heading]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="block"
|
||||
style={{ width: 260, height: 32 }}
|
||||
aria-label={
|
||||
heading !== null ? `Heading ${Math.round(heading)}°` : "No heading data"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FpvHud({ flight, onExit }: FpvHudProps) {
|
||||
const altFeet =
|
||||
flight.baroAltitude !== null && Number.isFinite(flight.baroAltitude)
|
||||
? Math.round(flight.baroAltitude * 3.28084)
|
||||
: null;
|
||||
const speedKts =
|
||||
flight.velocity !== null && Number.isFinite(flight.velocity)
|
||||
? Math.round(flight.velocity * 1.944)
|
||||
: null;
|
||||
const heading =
|
||||
flight.trueTrack !== null && Number.isFinite(flight.trueTrack)
|
||||
? flight.trueTrack
|
||||
: null;
|
||||
const cardinal = heading !== null ? headingToCardinal(heading) : null;
|
||||
const vs = flight.verticalRate;
|
||||
const vsFpm =
|
||||
vs !== null && Number.isFinite(vs) ? Math.round(vs * 196.85) : null;
|
||||
const vsDisplay = vsFpm !== null ? `${vsFpm > 0 ? "+" : ""}${vsFpm}` : null;
|
||||
const airline = useMemo(
|
||||
() => lookupAirline(flight.callsign),
|
||||
[flight.callsign],
|
||||
);
|
||||
const logoUrl = useMemo(() => {
|
||||
return airlineLogoCandidates(airline)[0] ?? null;
|
||||
}, [airline]);
|
||||
const [logoErrorUrl, setLogoErrorUrl] = useState<string | null>(null);
|
||||
const logoError = logoUrl !== null && logoUrl === logoErrorUrl;
|
||||
const vsIcon =
|
||||
vs !== null && Number.isFinite(vs) ? (
|
||||
vs > 0.5 ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : vs < -0.5 ? (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<Minus className="h-3 w-3" />
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="pointer-events-auto fixed bottom-[calc(1.5rem+env(safe-area-inset-bottom))] left-1/2 z-50 -translate-x-1/2 sm:bottom-[calc(1.5rem+env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div
|
||||
className="flex w-[min(92vw,460px)] flex-col items-center gap-0 overflow-hidden rounded-xl border border-white/8 bg-black/70 pb-1 shadow-[0_8px_32px_rgba(0,0,0,0.6)] backdrop-blur-3xl md:w-max"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="First person view flight instruments"
|
||||
>
|
||||
<div className="w-full border-b border-white/6 px-2 pt-1.5 pb-0.5 sm:px-2.5">
|
||||
<div
|
||||
className="mx-auto w-fit overflow-hidden rounded-md"
|
||||
style={{ width: 260 }}
|
||||
>
|
||||
<CompassRibbon heading={heading} />
|
||||
</div>
|
||||
<p className="mt-0 text-center text-[10px] font-bold tabular-nums text-sky-400/70">
|
||||
{heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-stretch">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 border-r border-white/6 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||
{logoUrl && !logoError ? (
|
||||
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/95 shadow-sm ring-1 ring-white/20">
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={airline ? `${airline} logo` : "Airline logo"}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain p-1"
|
||||
unoptimized
|
||||
onError={() => setLogoErrorUrl(logoUrl)}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 ring-1 ring-white/10">
|
||||
<Eye className="h-3.5 w-3.5 text-emerald-400/70 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[12px] font-bold tracking-wide text-white/90 sm:text-[13px]">
|
||||
{formatCallsign(flight.callsign)}
|
||||
</p>
|
||||
<p className="truncate text-[9px] font-medium uppercase tracking-widest text-white/30">
|
||||
{airline ?? flight.originCountry}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-12 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
|
||||
<div className="flex items-center gap-0.5 text-white/30">
|
||||
<ArrowUp className="h-2 w-2" />
|
||||
<span className="text-[8px] font-semibold uppercase tracking-wider">
|
||||
ALT
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-bold tabular-nums text-white/90">
|
||||
{altFeet !== null ? altFeet.toLocaleString() : "—"}
|
||||
</p>
|
||||
<p className="text-[8px] font-medium text-white/25">ft</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-11 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-14 sm:px-2.5">
|
||||
<div className="flex items-center gap-0.5 text-white/30">
|
||||
<Gauge className="h-2 w-2" />
|
||||
<span className="text-[8px] font-semibold uppercase tracking-wider">
|
||||
SPD
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-bold tabular-nums text-white/90">
|
||||
{speedKts ?? "—"}
|
||||
</p>
|
||||
<p className="text-[8px] font-medium text-white/25">kts</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-12 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
|
||||
<div className="flex items-center gap-0.5 text-white/30">
|
||||
{vsIcon ?? <Minus className="h-2 w-2" />}
|
||||
<span className="text-[8px] font-semibold uppercase tracking-wider">
|
||||
V/S
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`text-[13px] font-bold tabular-nums ${
|
||||
vs !== null && vs > 0.5
|
||||
? "text-emerald-400/80"
|
||||
: vs !== null && vs < -0.5
|
||||
? "text-amber-400/80"
|
||||
: "text-white/90"
|
||||
}`}
|
||||
>
|
||||
{vsDisplay ?? "—"}
|
||||
</p>
|
||||
<p className="text-[8px] font-medium text-white/25">fpm</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onExit}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-white/40 transition-colors hover:bg-white/5 hover:text-white/60 sm:px-2.5"
|
||||
aria-label="Exit first person view"
|
||||
title="Exit FPV (Esc)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ const SHORTCUTS = [
|
||||
{ key: "R", description: "Reset view" },
|
||||
{ key: "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;
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(window.innerWidth >= SM_BREAKPOINT);
|
||||
}, []);
|
||||
|
||||
// Close on outside click for small screens
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
|
||||
Reference in New Issue
Block a user