feat: 3D aircraft model overhaul and multi-source flight data proxy (Resolves #15) (#16)

* Refactor aircraft photo and hero banner components to reset loading state on photo change

- Updated Lightbox component to reset image loading state when navigating between photos.
- Modified HeroBanner component to reset loading state when the photo changes.

Clean up control panel search logic

- Removed unnecessary hasResults variable in SearchContent component.

Implement flight API client with fallback mechanism

- Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky).
- Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects.
- Created flight-api-types for shared types between API responses.

Refactor useFlights hook to utilize new flight API client

- Updated useFlights hook to fetch flights using the new flight API client.
- Removed credit management logic as it is no longer applicable with the new API structure.

Fix useFlightMonitors to fetch flight data by hex address

- Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24.

Update geo utility function for better readability

- Refactored splitAtAntimeridian function to improve variable naming and clarity.

Enhance OpenSky types with additional fields

- Added typeCode and registration fields to FlightState type for better integration with readsb data.

* fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing)

* fix: improve keyboard shortcuts help focus trapping

feat: add showAirspace option to MapAttribution component

fix: clear hideTimer on ScrollArea cleanup

refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors

fix: handle sessionStorage availability in useFlightTrack

refactor: increase POLL_INTERVAL_MS in useFlights for better performance

fix: optimize keyboard shortcuts dialog check

refactor: optimize useMergedTrails by caching selected flight position

feat: extend Settings type with airspace options

refactor: improve airline logo normalization functions

refactor: enhance flight API client with serialized rate limiting

refactor: optimize registration country lookup with pre-built maps

refactor: enhance logo cache management with size limits

feat: update map attribution to include airspace option

fix: validate rawState in parseStateRow function

refactor: improve utility functions with clamp implementation

* feat: add ATC lookup functionality and GPU memory monitoring

- Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code.
- Introduced `atc-types.ts` to define types and priorities for ATC feeds.
- Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports.
- Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks.

* feat: enhance flight data handling and improve API resilience

- Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures.
- Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval.
- Updated useMergedTrails to include timestamps for trail points.
- Removed smoothAnimations setting from useSettings as it is no longer needed.
- Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios.
- Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries.
- Updated flight API parsing to reject non-JSON responses from OpenSky and other providers.
- Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data.

* feat: migrate aircraft models to Cloudinary CDN and update mapping logic

* fix: adjust UI component styles and improve trail smoothing parameters

* fix: adjust base aircraft size for improved rendering

* feat: update changelog with recent enhancements and modify data source attribution

* fix: update model optimization details and remove Draco compression dependency

* feat: update changelog with recent code review fixes and fallback provider adjustments
This commit is contained in:
kew
2026-03-23 01:25:11 +05:30
committed by GitHub
parent 147b69b944
commit eb1103f63f
95 changed files with 9667 additions and 1384 deletions

View File

@ -0,0 +1,340 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Satellite, X, ChevronUp, Circle } from "lucide-react";
import {
getCircuitState,
getProviderOverride,
type CircuitState,
} from "@/lib/flight-api-client";
import type { ProviderName } from "@/lib/flight-api";
import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss";
// ── Provider definitions ───────────────────────────────────────────────
interface ProviderInfo {
id: ProviderName;
label: string;
description: string;
}
const PROVIDERS: ProviderInfo[] = [
{ id: "adsb", label: "adsb.lol", description: "Primary — server proxy" },
{
id: "opensky",
label: "OpenSky",
description: "Fallback — limited credits",
},
{
id: "airplanes",
label: "Airplanes.live",
description: "Direct — CORS restricted",
},
];
const SOURCE_LABELS: Record<string, string> = {
adsb: "adsb.lol",
opensky: "OpenSky",
airplanes: "Airplanes.live",
none: "Unavailable",
};
const SOURCE_COLORS: Record<string, string> = {
adsb: "rgb(52, 211, 153)", // emerald
opensky: "rgb(251, 191, 36)", // amber
airplanes: "rgb(96, 165, 250)", // blue
none: "rgb(248, 113, 113)", // red
};
function circuitBadge(
state: CircuitState,
cooldownMs: number,
): { label: string; color: string } {
switch (state) {
case "closed":
return { label: "OK", color: "rgb(52, 211, 153)" };
case "open":
return {
label: `DOWN ${Math.ceil(cooldownMs / 1000)}s`,
color: "rgb(248, 113, 113)",
};
case "half-open":
return { label: "PROBING", color: "rgb(251, 191, 36)" };
}
}
function setProviderOverride(provider: ProviderName | "auto"): void {
const url = new URL(window.location.href);
if (provider === "auto") {
url.searchParams.delete("provider");
} else {
url.searchParams.set("provider", provider);
}
window.history.replaceState({}, "", url.toString());
}
// ── Provider Dropdown ──────────────────────────────────────────────────
export type ProviderDropdownProps = {
open: boolean;
onClose: () => void;
currentSource: string | null;
};
export function ProviderDropdown({
open,
onClose,
currentSource,
}: ProviderDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
useDropdownDismiss(dropdownRef, open, onClose);
const [override, setOverride] = useState(() => getProviderOverride());
const isAutoMode = override === "auto";
const isDev =
typeof window !== "undefined" &&
(window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const handleSelect = useCallback(
(provider: ProviderName | "auto") => {
setProviderOverride(provider);
setOverride(provider === "auto" ? "auto" : provider);
onClose();
},
[onClose],
);
return (
<AnimatePresence>
{open && (
<motion.div
ref={dropdownRef}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
style={{
borderColor: "rgb(var(--ui-fg) / 0.08)",
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-2.5"
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
>
<div className="flex items-center gap-2">
<Satellite className="h-3 w-3 text-emerald-400/70" />
<span
className="text-[10px] font-semibold tracking-widest uppercase"
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
>
Providers
</span>
</div>
<button
type="button"
onClick={onClose}
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-white/5 active:bg-white/10"
aria-label="Close provider selector"
>
<X
className="h-3 w-3"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
/>
</button>
</div>
{/* Provider list */}
<div className="py-1">
{/* Auto option */}
<button
type="button"
onClick={() => handleSelect("auto")}
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
isAutoMode ? "bg-white/6" : "hover:bg-white/3 active:bg-white/6"
}`}
>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<Circle
className="h-2.5 w-2.5"
style={{
color: isAutoMode
? "rgb(52, 211, 153)"
: "rgb(var(--ui-fg) / 0.2)",
}}
fill={isAutoMode ? "rgb(52, 211, 153)" : "transparent"}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
<span
className="truncate text-[11px] font-medium leading-snug"
style={{
color: isAutoMode
? "rgb(var(--ui-fg) / 0.85)"
: "rgb(var(--ui-fg) / 0.55)",
}}
>
Auto
</span>
<span
className="text-[9px] leading-snug"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
Uses best available
</span>
</div>
<span
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
style={{
backgroundColor: "rgb(52, 211, 153, 0.07)",
color: "rgb(52, 211, 153)",
}}
>
REC
</span>
</button>
{/* Individual providers */}
{PROVIDERS.map((provider) => {
const isSelected = override === provider.id;
const isActive = currentSource === provider.id;
const circuit = getCircuitState(provider.id);
const badge = circuitBadge(
circuit.state,
circuit.cooldownRemaining,
);
const isAvailable = provider.id !== "airplanes" || isDev;
return (
<button
key={provider.id}
type="button"
onClick={() => isAvailable && handleSelect(provider.id)}
disabled={!isAvailable}
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
isSelected
? "bg-white/6"
: isAvailable
? "hover:bg-white/3 active:bg-white/6"
: "cursor-not-allowed opacity-40"
}`}
>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<Circle
className="h-2.5 w-2.5"
style={{
color: isActive
? (SOURCE_COLORS[provider.id] ??
"rgb(var(--ui-fg) / 0.2)")
: isSelected
? "rgb(var(--ui-fg) / 0.5)"
: "rgb(var(--ui-fg) / 0.2)",
}}
fill={
isActive
? (SOURCE_COLORS[provider.id] ?? "transparent")
: "transparent"
}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
<span
className="truncate text-[11px] font-medium leading-snug"
style={{
color: isActive
? "rgb(var(--ui-fg) / 0.85)"
: "rgb(var(--ui-fg) / 0.55)",
}}
>
{provider.label}
</span>
<span
className="text-[9px] leading-snug"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
{!isAvailable
? "CORS restricted — dev only"
: provider.description}
</span>
</div>
<span
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
style={{
backgroundColor: `${badge.color}12`,
color: isAvailable
? badge.color
: "rgb(var(--ui-fg) / 0.25)",
}}
>
{isAvailable ? badge.label : "CORS"}
</span>
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
// ── Provider Trigger (for status bar) ──────────────────────────────────
export type ProviderTriggerProps = {
source: string | null;
loading: boolean;
rateLimited: boolean;
onClick: () => void;
};
export function ProviderTrigger({
source,
loading,
rateLimited,
onClick,
}: ProviderTriggerProps) {
const label = rateLimited
? "Paused"
: loading && !source
? "Connecting…"
: source
? (SOURCE_LABELS[source] ?? source)
: "Connecting…";
const dotColor = rateLimited
? "text-amber-400/80"
: source === "none"
? "text-red-400/80"
: source === "opensky"
? "text-amber-400/80"
: source === "airplanes"
? "text-blue-400/80"
: "text-emerald-400/80";
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-2"
aria-label="Select ADS-B provider"
>
<div className="relative">
<Satellite className={`h-3 w-3 ${dotColor}`} />
</div>
<span
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
>
{label}
</span>
<ChevronUp
className="h-3 w-3 transition-colors"
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
/>
</button>
);
}