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

@ -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>
);
}

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];

View File

@ -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>
)}

View 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>
);
}

View File

@ -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;

View File

@ -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;